dsh-winrm 0.2.0 → 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/src/powershell.ts CHANGED
@@ -152,6 +152,57 @@ export function powershellCommandLine(): string {
152
152
  return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(OUTER_SCRIPT)
153
153
  }
154
154
 
155
+ /**
156
+ * Bootstrap for the streaming upload path: read `<byteCount>\n<script>` from
157
+ * stdin, then run that script **in this process** so it can keep draining the
158
+ * same stdin stream.
159
+ *
160
+ * This exists because {@link psReceiveStream} outgrew the command line. WinRS
161
+ * hands the command line to cmd.exe, whose limit is about 8191 characters, and
162
+ * the receiver is 3424 characters of PowerShell — 9206 once base64-of-UTF-16
163
+ * expands it, over the cap. Shipping the receiver on stdin instead costs one
164
+ * extra `Send` and keeps the command line a constant 1154 characters no matter
165
+ * how the receiver grows.
166
+ *
167
+ * Running the receiver in-process (rather than in a child, as
168
+ * {@link OUTER_SCRIPT} does) is what makes the handoff work: the child would
169
+ * need its own stdin, and the payload has to keep flowing into whatever reads
170
+ * the file. A fresh `[Console]::OpenStandardInput()` inside the receiver picks
171
+ * up exactly where this bootstrap stopped reading, because `Read` on the raw
172
+ * stream never buffers ahead of the count it was asked for.
173
+ */
174
+ const STREAM_BOOTSTRAP = [
175
+ "$ErrorActionPreference='Continue'",
176
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8',
177
+ '$i=[Console]::OpenStandardInput()',
178
+ "$d=''",
179
+ 'while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}',
180
+ '$n=[int]$d',
181
+ '$u=New-Object byte[] $n',
182
+ '$r=0',
183
+ 'while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}',
184
+ '$s=[Text.Encoding]::UTF8.GetString($u,0,$r)',
185
+ '$c=0',
186
+ 'try{',
187
+ ' &([scriptblock]::Create($s))',
188
+ '}catch{',
189
+ ' [Console]::Error.WriteLine([string]($_|Out-String))',
190
+ ' $c=1',
191
+ '}',
192
+ 'exit $c',
193
+ '',
194
+ ].join('\r\n')
195
+
196
+ /**
197
+ * The constant powershell.exe command line for a streaming upload.
198
+ *
199
+ * The receiver travels on stdin right behind it, so this stays well clear of
200
+ * cmd.exe's cap and does not change when the receiver does.
201
+ */
202
+ export function streamCommandLine(): string {
203
+ return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(STREAM_BOOTSTRAP)
204
+ }
205
+
155
206
  /**
156
207
  * The stdin payload for one command: the UTF-8 **byte** count, a newline, then
157
208
  * the raw script.
@@ -245,11 +296,16 @@ export function psListDir(dir: string): string {
245
296
  ].join('\r\n')
246
297
  }
247
298
 
248
- /** Plain: remote file size in bytes (0 when missing). */
299
+ /**
300
+ * Plain: remote file size in bytes. Emits nothing at all when the path is
301
+ * missing or is a directory, so the caller can tell "absent" from "empty" —
302
+ * both used to collapse to the string "0", which made a download of a
303
+ * nonexistent file look like a successful transfer of a zero-byte one.
304
+ */
249
305
  export function psFileSize(p: string): string {
250
306
  return [
251
307
  '$__f = Get-Item -LiteralPath ' + sq(p) + ' -ErrorAction SilentlyContinue',
252
- 'if ($null -eq $__f) { "0" } elseif ($__f.PSIsContainer) { "0" } else { [string]$__f.Length }',
308
+ 'if ($null -ne $__f -and -not $__f.PSIsContainer) { [string]$__f.Length }',
253
309
  '',
254
310
  ].join('\r\n')
255
311
  }
@@ -287,6 +343,246 @@ export function psWriteChunk(p: string, b64: string, append: boolean): string {
287
343
  ].join('\r\n')
288
344
  }
289
345
 
346
+ /** Magic stamped into the frame header that precedes every stream chunk. */
347
+ export const STREAM_FRAME_MAGIC = 'DSHZ'
348
+
349
+ /** Frame header size: 4 magic bytes + 4 little-endian length bytes + 1 mode byte + 1 reserved. */
350
+ export const STREAM_FRAME_HEADER_BYTES = 10
351
+
352
+ /** Frame mode: the payload after the header is a gzip member. */
353
+ export const STREAM_FRAME_GZIP = 1
354
+
355
+ /** Frame mode: the payload after the header is raw file bytes. */
356
+ export const STREAM_FRAME_RAW = 0
357
+
358
+ /** Frame mode: the payload after the header is the transfer's metadata. */
359
+ export const STREAM_FRAME_HEADER = 2
360
+
361
+ /** Remote-side buffer used while draining stdin (or inflating a frame) into the file. */
362
+ const STREAM_BUFFER_BYTES = 262_144
363
+
364
+ /**
365
+ * Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
366
+ *
367
+ * The frame is what makes a compressed stream legal on a pipe. stdin cannot
368
+ * seek, so the receiver has to be handed the exact byte count of the next gzip
369
+ * member before it lets `GZipStream` touch it; otherwise the inflater would
370
+ * read into the member that follows and the stream would desynchronize. The
371
+ * mode byte travels with the payload so the client can switch between gzip and
372
+ * raw per chunk.
373
+ * @param payload - gzip member bytes or raw file bytes.
374
+ * @param mode - `STREAM_FRAME_GZIP` or `STREAM_FRAME_RAW`.
375
+ * @returns the framed chunk to place on the stdin stream.
376
+ */
377
+ export function buildStreamFrame(payload: Buffer, mode: number): Buffer {
378
+ const frame = Buffer.alloc(STREAM_FRAME_HEADER_BYTES + payload.length)
379
+ frame.write(STREAM_FRAME_MAGIC, 0, 'ascii')
380
+ frame.writeUInt32LE(payload.length, 4)
381
+ frame[8] = mode
382
+ frame[9] = 0
383
+ payload.copy(frame, STREAM_FRAME_HEADER_BYTES)
384
+ return frame
385
+ }
386
+
387
+ /**
388
+ * Streaming receiver: drain framed stdin chunks into the file named by the
389
+ * header frame, until the declared byte count lands.
390
+ *
391
+ * This is the fast upload path. Instead of shipping base64 text inside a
392
+ * PowerShell script — which costs one full shell create/command/delete cycle
393
+ * per 48 KiB — the remote shell starts this script once and the client then
394
+ * pushes bytes straight down the WinRS stdin stream. The only per-chunk cost
395
+ * left is a single `Send`, so a whole file costs one shell and one command no
396
+ * matter how large it is.
397
+ *
398
+ * The script deliberately takes **no arguments**. The destination path and
399
+ * total byte count arrive as the first frame on stdin, which keeps the command
400
+ * line a constant size: WinRS hands the command line to cmd.exe, whose limit
401
+ * is about 8191 characters, and a long remote path interpolated into the
402
+ * script would eat into that budget (the script is base64-of-UTF-16 on the
403
+ * wire, so every character costs 2.67). Reading them from the stream removes
404
+ * the limit entirely and mirrors how `powershellCommandLine` stays constant.
405
+ *
406
+ * Every wire chunk is a frame: `DSHZ` + 4-byte little-endian payload length +
407
+ * 1 mode byte + 1 reserved, followed by exactly that many payload bytes. The
408
+ * explicit frame is what makes a *pipe* usable for a compressed stream: stdin
409
+ * cannot seek, so the receiver must be told how long the next gzip member is
410
+ * before it hands the bytes to `GZipStream`, or the inflater would read into
411
+ * the member that follows. The mode byte lets the client pick per chunk — gzip
412
+ * when it actually shrinks the data, raw bytes when it would not — so
413
+ * incompressible files never pay compression overhead.
414
+ *
415
+ * The script prints the three-line envelope on stdout with the byte count as
416
+ * its payload, so the caller can prove the transfer landed complete.
417
+ * @returns a PowerShell script suitable for `-EncodedCommand`.
418
+ */
419
+ export function psReceiveStream(): string {
420
+ return [
421
+ "$ErrorActionPreference = 'Stop'",
422
+ "$__a = ''",
423
+ "$__e = ''",
424
+ '$__c = 0',
425
+ 'try {',
426
+ ' $__i = [Console]::OpenStandardInput()',
427
+ ' $__hdr = New-Object byte[] ' + String(STREAM_FRAME_HEADER_BYTES),
428
+ ' $__got = 0',
429
+ ' while ($__got -lt $__hdr.Length) {',
430
+ ' $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)',
431
+ ' if ($__k -le 0) { break }',
432
+ ' $__got += $__k',
433
+ ' }',
434
+ ' if ($__got -lt $__hdr.Length) { throw ' + sq('upload stream ended before its header') + ' }',
435
+ ' 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') + ' }',
436
+ ' if ($__hdr[8] -ne ' + String(STREAM_FRAME_HEADER) + ') { throw ' + sq('upload stream started without a header frame') + ' }',
437
+ ' $__size = 0',
438
+ ' for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }',
439
+ ' $__meta = New-Object byte[] $__size',
440
+ ' $__got = 0',
441
+ ' while ($__got -lt $__size) {',
442
+ ' $__k = $__i.Read($__meta, $__got, $__size - $__got)',
443
+ ' if ($__k -le 0) { break }',
444
+ ' $__got += $__k',
445
+ ' }',
446
+ ' if ($__got -lt $__size) { throw ' + sq('upload stream truncated its header frame') + ' }',
447
+ ' $__text = [System.Text.Encoding]::UTF8.GetString($__meta)',
448
+ ' $__cut = $__text.IndexOf([char]10)',
449
+ ' if ($__cut -lt 0) { throw ' + sq('upload header frame is malformed') + ' }',
450
+ ' $__n = [long]$__text.Substring(0, $__cut)',
451
+ ' $__p = $__text.Substring($__cut + 1)',
452
+ ' $__d = [System.IO.Path]::GetDirectoryName($__p)',
453
+ ' if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }',
454
+ ' $__fs = New-Object System.IO.FileStream($__p, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)',
455
+ ' $__w = 0',
456
+ ' try {',
457
+ ' $__buf = New-Object byte[] ' + String(STREAM_BUFFER_BYTES),
458
+ ' while ($__w -lt $__n) {',
459
+ ' $__got = 0',
460
+ ' while ($__got -lt $__hdr.Length) {',
461
+ ' $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)',
462
+ ' if ($__k -le 0) { break }',
463
+ ' $__got += $__k',
464
+ ' }',
465
+ ' if ($__got -lt $__hdr.Length) { break }',
466
+ ' 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') + ' }',
467
+ ' $__size = 0',
468
+ ' for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }',
469
+ ' $__mode = $__hdr[8]',
470
+ ' $__blob = New-Object byte[] $__size',
471
+ ' $__got = 0',
472
+ ' while ($__got -lt $__size) {',
473
+ ' $__k = $__i.Read($__blob, $__got, $__size - $__got)',
474
+ ' if ($__k -le 0) { break }',
475
+ ' $__got += $__k',
476
+ ' }',
477
+ ' if ($__got -lt $__size) { break }',
478
+ ' if ($__mode -eq ' + String(STREAM_FRAME_GZIP) + ') {',
479
+ ' $__ms = New-Object System.IO.MemoryStream(, $__blob)',
480
+ ' $__gz = New-Object System.IO.Compression.GZipStream($__ms, [System.IO.Compression.CompressionMode]::Decompress)',
481
+ ' try {',
482
+ ' while ($true) {',
483
+ ' $__k = $__gz.Read($__buf, 0, $__buf.Length)',
484
+ ' if ($__k -le 0) { break }',
485
+ ' $__fs.Write($__buf, 0, $__k)',
486
+ ' $__w += $__k',
487
+ ' }',
488
+ ' } finally { $__gz.Dispose(); $__ms.Dispose() }',
489
+ ' } else {',
490
+ ' $__fs.Write($__blob, 0, $__blob.Length)',
491
+ ' $__w += $__blob.Length',
492
+ ' }',
493
+ ' }',
494
+ ' } finally { $__fs.Dispose() }',
495
+ ' $__a = [string]$__w',
496
+ '} catch {',
497
+ ' $__a = [string]$__a + [string]($_.Exception.Message)',
498
+ ' $__c = 1',
499
+ '}',
500
+ sq(ENVELOPE_MARKER) + ' + [string]$__c',
501
+ "'O:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__a))",
502
+ "'E:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__e))",
503
+ '',
504
+ ].join('\r\n')
505
+ }
506
+
507
+ /** Remote-side read buffer used by {@link psSendStream} while draining the file to stdout. */
508
+ const SEND_BUFFER_BYTES = 262_144
509
+
510
+ /**
511
+ * Streaming sender: write one byte range of the file to standard output,
512
+ * gzip-compressed, for the download path.
513
+ *
514
+ * This is the mirror of {@link psReceiveStream}. The old download path asked
515
+ * for 48 KiB at a time, and every chunk cost a full shell create / command /
516
+ * delete cycle — five HTTP round trips each — so a 2 MiB file took 129 s. Here
517
+ * the shell is created once and the client then simply receives; the only
518
+ * per-chunk cost left is one `Receive`, and the target streams the file as fast
519
+ * as the transport will drain it.
520
+ *
521
+ * Two details make this work:
522
+ *
523
+ * - **Only stderr may carry text.** Anything written to the PowerShell success
524
+ * stream would be merged into the payload and corrupt the file, so the byte
525
+ * count and any error go to `[Console]::Error` and the exit code, not stdout.
526
+ * - **The range is positional.** `Position` plus a byte count lets several
527
+ * shells serve different parts of one file concurrently; each writes to its
528
+ * own offset on the client, so completion order does not matter.
529
+ *
530
+ * Output is gzip-compressed unconditionally. Measured against this target, a
531
+ * compressible 16 MiB file collapsed from 121 round trips to 1 (2.0 s), and an
532
+ * incompressible 2 MiB file was no slower than raw (9457 ms vs 9582 ms), so the
533
+ * compression pays for itself or costs nothing.
534
+ * @param remotePath - file to read on the target.
535
+ * @param start - first byte offset of this worker's range.
536
+ * @param length - number of bytes this worker must send.
537
+ * @returns a PowerShell script suitable for `-EncodedCommand`.
538
+ */
539
+ export function psSendStream(remotePath: string, start: number, length: number): string {
540
+ return [
541
+ "$ErrorActionPreference = 'Stop'",
542
+ '$__o = [Console]::OpenStandardOutput()',
543
+ '$__n = 0',
544
+ 'try {',
545
+ ' $__fs = [System.IO.File]::OpenRead(' + sq(remotePath) + ')',
546
+ ' try {',
547
+ ' $__fs.Position = [long]' + String(start),
548
+ ' $__left = [long]' + String(length),
549
+ ' $__gz = New-Object System.IO.Compression.GZipStream($__o, [System.IO.Compression.CompressionMode]::Compress, $true)',
550
+ ' try {',
551
+ ' $__buf = New-Object byte[] ' + String(SEND_BUFFER_BYTES),
552
+ ' while ($__left -gt 0) {',
553
+ ' $__want = [int][Math]::Min([long]$__buf.Length, $__left)',
554
+ ' $__k = $__fs.Read($__buf, 0, $__want)',
555
+ ' if ($__k -le 0) { break }',
556
+ ' $__gz.Write($__buf, 0, $__k)',
557
+ ' $__n += $__k',
558
+ ' $__left -= $__k',
559
+ ' }',
560
+ ' $__gz.Flush()',
561
+ ' } finally { $__gz.Dispose() }',
562
+ ' $__o.Flush()',
563
+ ' } finally { $__fs.Dispose() }',
564
+ " [Console]::Error.WriteLine('__DSH_DL__ OK ' + [string]$__n)",
565
+ '} catch {',
566
+ " [Console]::Error.WriteLine('__DSH_DL__ ERR ' + [string]($_.Exception.Message))",
567
+ ' throw',
568
+ '}',
569
+ '',
570
+ ].join('\r\n')
571
+ }
572
+
573
+ /**
574
+ * Wrap an arbitrary script in the constant powershell.exe invocation.
575
+ *
576
+ * Only safe for scripts that fit cmd.exe's ~8191 character command line (the
577
+ * script is base64-of-UTF-16 on the wire, so it costs 2.67 characters each);
578
+ * {@link psReceiveStream} outgrew that and travels on stdin instead.
579
+ * @param script - the PowerShell script to run.
580
+ * @returns the command line to hand to WinRS.
581
+ */
582
+ export function scriptCommandLine(script: string): string {
583
+ return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(script)
584
+ }
585
+
290
586
  /** Decode one base64 envelope field, tolerating whitespace the transport added. */
291
587
  function decodeField(base64: string): string {
292
588
  return Buffer.from(base64.replace(/\s+/g, ''), 'base64').toString('utf8')
package/src/smb.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * SMB transfer bridge.
3
3
  *
4
- * The WinRM base64 chunk bridge is reliable for small files but breaks for
5
- * large artifacts (the WinRM/HTTP envelope rejects large payloads), so whole
6
- * files ride the remote admin share. This module mounts the remote host's
7
- * admin share with `net use` (password passed via stdin, never argv), copies
8
- * whole files with node:fs, then verifies the SHA-256 on both ends.
4
+ * SMB is the fast channel when the admin share is reachable, so `auto` tries it
5
+ * first and falls back to the WinRM streaming path. This module mounts the
6
+ * remote host's admin share with `net use` (password passed via stdin, never
7
+ * argv), copies whole files with node:fs, then verifies the SHA-256 on both
8
+ * ends.
9
9
  */
10
10
 
11
11
  import { spawn } from 'node:child_process'
12
12
  import { copyFile, stat } from 'node:fs/promises'
13
13
  import { createHash } from 'node:crypto'
14
14
  import { readFile } from 'node:fs/promises'
15
+ import { createConnection } from 'node:net'
15
16
  import type { WinHostEntry } from './protocol.ts'
16
17
 
17
18
  /** Per-transfer channel selector. */
@@ -85,6 +86,57 @@ async function remoteSha256(host: string, remotePath: string): Promise<string> {
85
86
  return match[1].toUpperCase()
86
87
  }
87
88
 
89
+ /** How long a reachability verdict is trusted before probing again. */
90
+ const SMB_PROBE_TTL_MS = 60_000
91
+
92
+ /** Budget for the TCP reachability probe; far below `net use`'s own timeout. */
93
+ const SMB_PROBE_TIMEOUT_MS = 3_000
94
+
95
+ const smbProbeCache = new Map<string, { reachable: boolean; at: number }>()
96
+
97
+ /**
98
+ * Whether the target's SMB port (445) accepts a connection.
99
+ *
100
+ * `net use` is the only way to mount the admin share, but it has no useful
101
+ * timeout: against a host whose 445 is filtered or whose SMB service is down it
102
+ * spends a full minute before failing. That minute was being paid on *every*
103
+ * `auto` upload before the WinRM fallback could even start — measured at 61 s
104
+ * and 58 s on the test host, against a 12 s streaming transfer. A bare TCP
105
+ * connect answers the same question in milliseconds.
106
+ *
107
+ * A negative verdict is cached briefly so a burst of transfers does not re-probe
108
+ * for each one; a positive verdict is cached too, but the `net use` that follows
109
+ * still reports its own failure if the share turns out to be unmountable.
110
+ * @param host - target hostname or address.
111
+ * @returns true when port 445 accepted a connection.
112
+ */
113
+ export async function smbReachable(host: string): Promise<boolean> {
114
+ const cached = smbProbeCache.get(host)
115
+ if (cached !== undefined && Date.now() - cached.at < SMB_PROBE_TTL_MS) return cached.reachable
116
+ const reachable = await new Promise<boolean>((resolve) => {
117
+ const socket = createConnection({ host, port: 445 })
118
+ let settled = false
119
+ const finish = (value: boolean): void => {
120
+ if (settled) return
121
+ settled = true
122
+ socket.destroy()
123
+ resolve(value)
124
+ }
125
+ const timer = setTimeout(() => finish(false), SMB_PROBE_TIMEOUT_MS)
126
+ socket.once('connect', () => { clearTimeout(timer); finish(true) })
127
+ socket.once('error', () => { clearTimeout(timer); finish(false) })
128
+ socket.once('timeout', () => { clearTimeout(timer); finish(false) })
129
+ socket.setTimeout(SMB_PROBE_TIMEOUT_MS)
130
+ })
131
+ smbProbeCache.set(host, { reachable, at: Date.now() })
132
+ return reachable
133
+ }
134
+
135
+ /** Error thrown when SMB was requested or probed and the target is not listening. */
136
+ export function smbUnavailable(host: string): Error {
137
+ return new Error('SMB is not reachable on ' + host + ':445 (the admin share needs TCP 445 open and the Server service running)')
138
+ }
139
+
88
140
  /**
89
141
  * Copy one local file to a remote Windows host over SMB, verifying the
90
142
  * remote SHA-256 matches the local source.
package/src/tools.ts CHANGED
@@ -272,7 +272,7 @@ export function winrmUploadTool(engine: WinRmEngine) {
272
272
  alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
273
273
  localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
274
274
  remotePath: { type: 'string', required: true, description: 'Destination path on the remote host, e.g. C:\\temp\\file.txt (parent dirs are created).' },
275
- channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (base64 chunks), or auto (default, SMB first with WinRM fallback).' },
275
+ channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (streamed over the WinRS stdin channel), or auto (default, SMB first with WinRM fallback).' },
276
276
  },
277
277
  output: {
278
278
  schema: {
@@ -311,7 +311,7 @@ export function winrmDownloadTool(engine: WinRmEngine) {
311
311
  alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
312
312
  remotePath: { type: 'string', required: true, description: 'Remote file path, e.g. C:\\temp\\file.txt.' },
313
313
  localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
314
- channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (base64 chunks), or auto (default, SMB first with WinRM fallback).' },
314
+ channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (streamed over parallel WinRS ranges), or auto (default, SMB first with WinRM fallback).' },
315
315
  },
316
316
  output: {
317
317
  schema: {