dsh-winrm 0.4.0 → 0.4.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/README.en.md CHANGED
@@ -123,8 +123,10 @@ Transport relies on the native Node.js [winrm-client](https://github.com/shide19
123
123
  - The console is a command session: it does not preserve PowerShell variables or the current directory, and every command runs independently
124
124
  - Uploads go over **one stdin stream**: a single shell, a single command, one `Send` per 256 KB, with per-chunk gzip for compressible content (a 1.7 MB JSON fixture travels as 91 KB on the wire). The old 48 KB chunked path is kept as a fallback, used only when the streaming channel is unavailable
125
125
  - Downloads go over **parallel byte ranges**: up to four workers by default, each with its own shell reading an absolute byte range of the same file, compressed on the remote side and written locally at the matching offset (measured: 2 MiB in about 10-12 s, 16 MiB in about 21-23 s, 16 MiB of compressible content in about 6 s; the old 48 KB chunked path needed 129 s for 2 MiB and remains as a fallback). A single server `Receive` returns at most 128 KB regardless of the envelope size, so downloads are round-trip bound rather than bandwidth bound
126
- - `channel=auto` (the default) probes port 445 first: `net use` has no short timeout of its own and takes about 60 s to fail when SMB is unavailable, and that wait used to happen before every upload. It is now decided within 3 s and the transfer goes straight over WinRM
126
+ - `channel=auto` (the default) probes port 445 first: `net use` has no short timeout of its own and takes about 60 s to fail when SMB is unavailable, and that wait used to happen before every upload. It is now decided within 3 s and the transfer goes straight over WinRM. An explicit `channel=smb` on a host whose 445 is closed fails immediately and names `winrm` as the way out
127
+ - A streaming transfer is bounded by **whether bytes are still moving**, not by a fixed deadline for the whole transfer: it keeps going as long as data arrives, and only a range that delivers nothing for 180 s is treated as stalled. A 700 MiB file therefore completes normally at the ~0.73 MiB/s this link measures even though it takes about sixteen minutes; the absolute cap exists only so a wedged transfer cannot stay alive forever, and it scales with the payload
127
128
  - A failed download leaves no residue: bytes already written are discarded and the target file is removed. If the failure happens before any byte arrives (missing path, target is a directory, host unreachable), an existing local file is left untouched — the target file is only created on the first write
129
+ - When an attempt is abandoned mid-transfer (for example a failed auth candidate retried from the start), the sender process on the target can outlive it and keep the file open until its shell is reclaimed; a transfer that finishes normally exits on its own. If a remote file refuses to delete, end the corresponding `powershell.exe` sender on the target first
128
130
  - A single command times out after 60 s by default; pass `timeoutMs` to change it
129
131
  - The target machine must already have WinRM enabled (see above); cleartext HTTP Basic must not be used on the public internet
130
132
  - Closing and reopening the panel returns to the Hosts tab: the panel occupies the `main` slot, so it unmounts when you switch back to the conversation and the active tab is not preserved across toggles
package/README.md CHANGED
@@ -123,8 +123,10 @@ scripts/
123
123
  - 控制台是命令会话,不保持 PowerShell 变量和当前目录状态;每条命令独立执行
124
124
  - 上传走**单条 stdin 流**:一个 shell、一个命令,每 256KB 一个 `Send`,可压缩内容自动按块 gzip(实测 1.7MB JSON 线上仅 91KB)。旧的 48KB 分块路径保留为回退,仅在流式通道不可用时启用
125
125
  - 下载走**并行区间流**:默认最多 4 个 worker,各自一个 shell 读同一文件的绝对字节区间,远端 gzip 后回传,本地按 offset 落位(实测 2MiB 约 10-12 秒,16MiB 约 21-23 秒,可压缩的 16MiB 约 6 秒;旧的 48KB 分块路径 2MiB 需 129 秒,保留为回退)。服务端单次 `Receive` 最多回 128KB,与信封大小无关,所以下载是往返次数受限而非带宽受限
126
- - `channel=auto`(默认)先探测目标机 445 端口:`net use` 自身没有短超时,SMB 不可用时要等约 60 秒才失败,此前这段等待发生在每次上传之前。现在 3 秒内判定并直接走 WinRM
127
- - 下载失败不留残file:已写入的数据会被丢弃、目标文件被删除;若失败发生在任何字节到达之前(路径不存在、目标是目录、主机不可达),已存在的本地文件保持原样不动(目标文件在首次写入时才创建)
126
+ - `channel=auto`(默认)先探测目标机 445 端口:`net use` 自身没有短超时,SMB 不可用时要等约 60 秒才失败,此前这段等待发生在每次上传之前。现在 3 秒内判定并直接走 WinRM;显式 `channel=smb` 且 445 未开放时立即报错,并提示改用 `winrm`
127
+ - 流式传输以**字节是否还在流动**判定超时,而不是给整次传输一个固定时限:只要还有数据到达就继续,某条区间连续 180 秒没有任何字节才判失败。因此大文件(实测 700MiB)在该链路的约 0.73MiB/s 速率下会跑十几分钟并正常完成;绝对上限只是防止传输卡死后永久占用,按载荷大小放大
128
+ - 下载失败不留残留文件:已写入的数据会被丢弃、目标文件被删除;若失败发生在任何字节到达之前(路径不存在、目标是目录、主机不可达),已存在的本地文件保持原样不动(目标文件在首次写入时才创建)
129
+ - 一次传输被中途放弃时(例如某次认证尝试失败后从头重试),目标机上的发送进程可能残留并继续占用该文件,直到其 shell 被回收;正常结束的传输会自行退出。遇到远端文件删不掉时,先结束目标机上对应的 `powershell.exe` 发送进程再删
128
130
  - 单命令默认 60s 超时,可传 `timeoutMs`
129
131
  - 目标机需已启用 WinRM(见上);HTTP 明文 Basic 不应用于公网
130
132
  - 面板关闭再打开会回到「主机」页签:面板是 `main` 插槽的占用者,切回对话时卸载,页签状态不跨开关保留
package/lib/client.js CHANGED
@@ -707,17 +707,17 @@ if (typeof document !== "undefined" && document.querySelector("style[data-plugin
707
707
  document.head.appendChild(tag);
708
708
  }
709
709
  var cards_module_css_default = {
710
+ "diffStat": "X_s05W_diffStat",
710
711
  "rowError": "X_s05W_rowError",
711
- "sep": "X_s05W_sep",
712
- "removed": "X_s05W_removed",
713
- "meta": "X_s05W_meta",
714
- "summary": "X_s05W_summary",
715
712
  "detail": "X_s05W_detail",
716
- "wrap": "X_s05W_wrap",
717
713
  "added": "X_s05W_added",
718
- "diffStat": "X_s05W_diffStat",
719
- "title": "X_s05W_title",
720
- "note": "X_s05W_note"
714
+ "note": "X_s05W_note",
715
+ "removed": "X_s05W_removed",
716
+ "wrap": "X_s05W_wrap",
717
+ "summary": "X_s05W_summary",
718
+ "sep": "X_s05W_sep",
719
+ "meta": "X_s05W_meta",
720
+ "title": "X_s05W_title"
721
721
  };
722
722
  //#endregion
723
723
  //#region src/client/tool/cards.tsx
@@ -1080,93 +1080,93 @@ if (typeof document !== "undefined" && document.querySelector("style[data-plugin
1080
1080
  document.head.appendChild(tag);
1081
1081
  }
1082
1082
  var panel_module_css_default = {
1083
- "groupToggle": "hAbY3W_groupToggle",
1084
- "dirSize": "hAbY3W_dirSize",
1085
- "tunnelRow": "hAbY3W_tunnelRow",
1086
- "actions": "hAbY3W_actions",
1087
- "primaryButton": "hAbY3W_primaryButton",
1088
- "search": "hAbY3W_search",
1089
- "cellPre": "hAbY3W_cellPre",
1090
- "consoleOutput": "hAbY3W_consoleOutput",
1091
- "controls": "hAbY3W_controls",
1092
- "utf8Note": "hAbY3W_utf8Note",
1093
- "formCard": "hAbY3W_formCard",
1094
- "inlineTest": "hAbY3W_inlineTest",
1095
- "iconButton": "hAbY3W_iconButton",
1096
- "loading": "hAbY3W_loading",
1097
- "browsePanel": "hAbY3W_browsePanel",
1098
- "clusterForm": "hAbY3W_clusterForm",
1099
- "spinner": "hAbY3W_spinner",
1100
- "groupSection": "hAbY3W_groupSection",
1101
- "modal": "hAbY3W_modal",
1102
- "radioRow": "hAbY3W_radioRow",
1103
- "consoleWrap": "hAbY3W_consoleWrap",
1104
- "groupChevron": "hAbY3W_groupChevron",
1105
- "table": "hAbY3W_table",
1106
- "empty": "hAbY3W_empty",
1107
- "toolbar": "hAbY3W_toolbar",
1108
- "termWrap": "hAbY3W_termWrap",
1109
- "dirType": "hAbY3W_dirType",
1110
- "cellDetails": "hAbY3W_cellDetails",
1111
- "badge": "hAbY3W_badge",
1112
- "progressTrack": "hAbY3W_progressTrack",
1113
- "panelTitle": "hAbY3W_panelTitle",
1114
- "termContainer": "hAbY3W_termContainer",
1083
+ "linkButton": "hAbY3W_linkButton",
1084
+ "browsePath": "hAbY3W_browsePath",
1115
1085
  "dirRow": "hAbY3W_dirRow",
1116
- "field": "hAbY3W_field",
1117
- "consoleHeader": "hAbY3W_consoleHeader",
1118
- "ghostButton": "hAbY3W_ghostButton",
1119
- "entry": "hAbY3W_entry",
1120
- "dialog": "hAbY3W_dialog",
1086
+ "dirType": "hAbY3W_dirType",
1087
+ "toolbarSpacer": "hAbY3W_toolbarSpacer",
1088
+ "dialogOverlay": "hAbY3W_dialogOverlay",
1089
+ "table": "hAbY3W_table",
1121
1090
  "backButton": "hAbY3W_backButton",
1122
- "consoleInputRow": "hAbY3W_consoleInputRow",
1091
+ "ghostButton": "hAbY3W_ghostButton",
1092
+ "dirName": "hAbY3W_dirName",
1093
+ "groupBySelect": "hAbY3W_groupBySelect",
1094
+ "dirSize": "hAbY3W_dirSize",
1095
+ "tabBody": "hAbY3W_tabBody",
1096
+ "tunnelLabel": "hAbY3W_tunnelLabel",
1097
+ "clusterForm": "hAbY3W_clusterForm",
1098
+ "checkRow": "hAbY3W_checkRow",
1123
1099
  "formError": "hAbY3W_formError",
1124
- "groupHeader": "hAbY3W_groupHeader",
1125
1100
  "progressBar": "hAbY3W_progressBar",
1126
- "clusterFilters": "hAbY3W_clusterFilters",
1127
- "dialogOverlay": "hAbY3W_dialogOverlay",
1128
- "tabBar": "hAbY3W_tabBar",
1129
- "browseHeader": "hAbY3W_browseHeader",
1130
- "dshSshSpin": "hAbY3W_dshSshSpin",
1131
- "entryLabel": "hAbY3W_entryLabel",
1132
- "hiddenFile": "hAbY3W_hiddenFile",
1133
- "dirName": "hAbY3W_dirName",
1134
- "panelHeader": "hAbY3W_panelHeader",
1135
- "modalFooter": "hAbY3W_modalFooter",
1101
+ "banner": "hAbY3W_banner",
1102
+ "modal": "hAbY3W_modal",
1103
+ "groupName": "hAbY3W_groupName",
1104
+ "input": "hAbY3W_input",
1105
+ "entryIcon": "hAbY3W_entryIcon",
1106
+ "hint": "hAbY3W_hint",
1107
+ "fillBody": "hAbY3W_fillBody",
1108
+ "transferBlock": "hAbY3W_transferBlock",
1109
+ "groupSection": "hAbY3W_groupSection",
1136
1110
  "modalTitle": "hAbY3W_modalTitle",
1111
+ "progressTrack": "hAbY3W_progressTrack",
1112
+ "actions": "hAbY3W_actions",
1137
1113
  "tab": "hAbY3W_tab",
1138
- "tabBody": "hAbY3W_tabBody",
1139
- "banner": "hAbY3W_banner",
1114
+ "utf8Note": "hAbY3W_utf8Note",
1115
+ "panelTitle": "hAbY3W_panelTitle",
1116
+ "consoleOutput": "hAbY3W_consoleOutput",
1117
+ "hiddenFile": "hAbY3W_hiddenFile",
1118
+ "tunnelRow": "hAbY3W_tunnelRow",
1119
+ "groupCount": "hAbY3W_groupCount",
1120
+ "spinner": "hAbY3W_spinner",
1121
+ "termPlaceholder": "hAbY3W_termPlaceholder",
1122
+ "mono": "hAbY3W_mono",
1123
+ "modalBackdrop": "hAbY3W_modalBackdrop",
1124
+ "cellDetails": "hAbY3W_cellDetails",
1140
1125
  "termBody": "hAbY3W_termBody",
1141
- "browseList": "hAbY3W_browseList",
1142
- "progressMeta": "hAbY3W_progressMeta",
1126
+ "cellPre": "hAbY3W_cellPre",
1127
+ "fieldHint": "hAbY3W_fieldHint",
1143
1128
  "tableWrap": "hAbY3W_tableWrap",
1144
- "browsePath": "hAbY3W_browsePath",
1145
- "tunnelList": "hAbY3W_tunnelList",
1146
- "entryIcon": "hAbY3W_entryIcon",
1147
- "checkRow": "hAbY3W_checkRow",
1148
- "toolbarSpacer": "hAbY3W_toolbarSpacer",
1129
+ "dshSshSpin": "hAbY3W_dshSshSpin",
1130
+ "field": "hAbY3W_field",
1131
+ "fieldLabel": "hAbY3W_fieldLabel",
1149
1132
  "commandInput": "hAbY3W_commandInput",
1150
- "termPlaceholder": "hAbY3W_termPlaceholder",
1151
- "groupBySelect": "hAbY3W_groupBySelect",
1152
- "transferBlock": "hAbY3W_transferBlock",
1153
- "fillBody": "hAbY3W_fillBody",
1154
- "input": "hAbY3W_input",
1155
- "tunnelLabel": "hAbY3W_tunnelLabel",
1133
+ "consoleWrap": "hAbY3W_consoleWrap",
1156
1134
  "panel": "hAbY3W_panel",
1157
- "groupCount": "hAbY3W_groupCount",
1158
- "linkButton": "hAbY3W_linkButton",
1159
- "formRow": "hAbY3W_formRow",
1160
1135
  "dialogTitle": "hAbY3W_dialogTitle",
1161
- "modalBackdrop": "hAbY3W_modalBackdrop",
1162
- "fieldHint": "hAbY3W_fieldHint",
1163
- "cellMuted": "hAbY3W_cellMuted",
1164
- "mono": "hAbY3W_mono",
1136
+ "browseHeader": "hAbY3W_browseHeader",
1137
+ "inlineTest": "hAbY3W_inlineTest",
1138
+ "browseList": "hAbY3W_browseList",
1139
+ "consoleHeader": "hAbY3W_consoleHeader",
1140
+ "consoleInputRow": "hAbY3W_consoleInputRow",
1141
+ "formRow": "hAbY3W_formRow",
1142
+ "badge": "hAbY3W_badge",
1143
+ "clusterFilters": "hAbY3W_clusterFilters",
1144
+ "primaryButton": "hAbY3W_primaryButton",
1145
+ "toolbar": "hAbY3W_toolbar",
1146
+ "groupToggle": "hAbY3W_groupToggle",
1147
+ "modalFooter": "hAbY3W_modalFooter",
1148
+ "radioRow": "hAbY3W_radioRow",
1149
+ "progressMeta": "hAbY3W_progressMeta",
1150
+ "search": "hAbY3W_search",
1151
+ "browsePanel": "hAbY3W_browsePanel",
1152
+ "termWrap": "hAbY3W_termWrap",
1153
+ "entry": "hAbY3W_entry",
1154
+ "controls": "hAbY3W_controls",
1155
+ "empty": "hAbY3W_empty",
1156
+ "panelHeader": "hAbY3W_panelHeader",
1157
+ "loading": "hAbY3W_loading",
1158
+ "dialog": "hAbY3W_dialog",
1159
+ "formCard": "hAbY3W_formCard",
1165
1160
  "panelContent": "hAbY3W_panelContent",
1166
- "fieldLabel": "hAbY3W_fieldLabel",
1167
- "hint": "hAbY3W_hint",
1161
+ "cellMuted": "hAbY3W_cellMuted",
1162
+ "tabBar": "hAbY3W_tabBar",
1163
+ "tunnelList": "hAbY3W_tunnelList",
1164
+ "entryLabel": "hAbY3W_entryLabel",
1165
+ "groupChevron": "hAbY3W_groupChevron",
1166
+ "iconButton": "hAbY3W_iconButton",
1168
1167
  "radioLabel": "hAbY3W_radioLabel",
1169
- "groupName": "hAbY3W_groupName"
1168
+ "groupHeader": "hAbY3W_groupHeader",
1169
+ "termContainer": "hAbY3W_termContainer"
1170
1170
  };
1171
1171
  //#endregion
1172
1172
  //#region src/client/panel/ConsoleTab.tsx
package/lib/index.js CHANGED
@@ -1307,6 +1307,41 @@ const STREAM_GZIP_MIN_RATIO = .85;
1307
1307
  /** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
1308
1308
  const STREAM_SEND_TIMEOUT_MS = 12e4;
1309
1309
  /**
1310
+ * How long a streaming transfer may deliver nothing before it is treated as
1311
+ * stalled.
1312
+ *
1313
+ * A wall-clock cap on the whole transfer cannot work here: this transport is
1314
+ * latency-bound and measured at ~0.73 MiB/s against a real target, so a 700 MiB
1315
+ * file legitimately needs about sixteen minutes and any smaller fixed cap fails
1316
+ * a transfer that is making steady progress. What a transfer actually needs
1317
+ * bounding against is a *stall* — a range that has stopped producing bytes —
1318
+ * which is what this idle window measures.
1319
+ */
1320
+ const STREAM_IDLE_TIMEOUT_MS = 18e4;
1321
+ /**
1322
+ * Floor used only to size the runaway backstop, never as a throughput target.
1323
+ * Deliberately pessimistic (64 KiB/s): it must not fail a slow link, only a
1324
+ * transfer that is somehow still "busy" hours after it should have finished.
1325
+ */
1326
+ const STREAM_MIN_BYTES_PER_SEC = 65536;
1327
+ /**
1328
+ * Budget for one streaming transfer of `bytes` bytes.
1329
+ *
1330
+ * The idle window is the effective limit; the hard window exists so a wedged
1331
+ * transfer cannot stay alive forever, and it scales with the payload because
1332
+ * the payload is what sets the legitimate duration.
1333
+ * @param bytes - total bytes the transfer must move.
1334
+ * @param idleMs - how long the transfer may go without delivering anything.
1335
+ * @returns the idle window and the absolute backstop, both in milliseconds.
1336
+ */
1337
+ function transferBudget(bytes, idleMs = STREAM_IDLE_TIMEOUT_MS) {
1338
+ const optimisticMs = Math.ceil(Math.max(0, bytes) / STREAM_MIN_BYTES_PER_SEC) * 1e3;
1339
+ return {
1340
+ idleMs,
1341
+ hardMs: Math.max(idleMs * 4, optimisticMs + idleMs)
1342
+ };
1343
+ }
1344
+ /**
1310
1345
  * One streaming transfer over a single shell: create → run the receiver →
1311
1346
  * push the receiver script, the header frame and every data frame down stdin →
1312
1347
  * receive the envelope → delete the shell.
@@ -1318,13 +1353,13 @@ const STREAM_SEND_TIMEOUT_MS = 12e4;
1318
1353
  * @param remotePath - destination path on the target.
1319
1354
  * @param data - the file contents to send.
1320
1355
  * @param onProgress - called with cumulative bytes after each frame.
1321
- * @param timeoutMs - whole-transfer budget.
1356
+ * @param budget - idle window and absolute backstop for the transfer.
1322
1357
  * @param authMethod - the auth scheme for this attempt.
1323
1358
  * @param sendHttp - the resolved library send function.
1324
1359
  * @returns the number of file bytes the receiver acknowledged writing.
1325
1360
  * @throws WinrmTimedOut when the deadline passes; other errors are transport failures.
1326
1361
  */
1327
- async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp) {
1362
+ async function streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp) {
1328
1363
  const base = baseParams(conn, authMethod);
1329
1364
  const target = {
1330
1365
  host: conn.host,
@@ -1336,7 +1371,9 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1336
1371
  useHttps: conn.useHttps ?? false,
1337
1372
  rejectUnauthorized: conn.rejectUnauthorized ?? true
1338
1373
  };
1339
- const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
1374
+ const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS;
1375
+ let lastProgressAt = Date.now();
1376
+ const stalled = () => Date.now() - lastProgressAt >= budget.idleMs;
1340
1377
  const state = {
1341
1378
  shellId: void 0,
1342
1379
  timedOut: false,
@@ -1365,7 +1402,7 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1365
1402
  let slice = STREAM_CHUNK_BYTES;
1366
1403
  let ceiling = STREAM_CHUNK_BYTES;
1367
1404
  while (offset < total) {
1368
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
1405
+ if (state.settled || stalled()) throw new WinrmTimedOut();
1369
1406
  const take = Math.min(slice, total - offset);
1370
1407
  const chunk = data.subarray(offset, offset + take);
1371
1408
  const gz = gzipSync(chunk, { level: 6 });
@@ -1382,6 +1419,7 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1382
1419
  throw error;
1383
1420
  }
1384
1421
  offset += take;
1422
+ lastProgressAt = Date.now();
1385
1423
  onProgress?.(offset, total);
1386
1424
  if (slice < ceiling) slice = Math.min(ceiling, slice * 2);
1387
1425
  }
@@ -1390,15 +1428,15 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1390
1428
  shellId,
1391
1429
  commandId
1392
1430
  };
1393
- const commandDeadline = Date.now() + timeoutMs;
1394
1431
  let stdout = "";
1395
1432
  let stderr = "";
1396
1433
  for (;;) {
1397
- if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut();
1434
+ if (state.settled || stalled()) throw new WinrmTimedOut();
1398
1435
  const chunk = await Command.doReceiveOutputNonBlocking(receive);
1399
1436
  stdout += chunk.output;
1400
1437
  stderr += chunk.stderr;
1401
1438
  if (chunk.isComplete) break;
1439
+ if (chunk.output !== "" || chunk.stderr !== "") lastProgressAt = Date.now();
1402
1440
  }
1403
1441
  const parsed = parseEnvelope(stdout);
1404
1442
  if (parsed === null) throw new Error("streaming upload did not report completion: " + (stripClixml(stderr) || stdout).trim());
@@ -1443,16 +1481,17 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1443
1481
  * @param remotePath - destination path on the target.
1444
1482
  * @param data - the file contents to send.
1445
1483
  * @param onProgress - called with cumulative bytes after each frame.
1446
- * @param timeoutMs - whole-transfer budget.
1484
+ * @param idleMs - how long the transfer may go without moving a slice.
1447
1485
  * @returns bytes written, or null when the streaming path is unavailable.
1448
1486
  */
1449
- async function uploadStream(conn, remotePath, data, onProgress, timeoutMs = 6e5) {
1487
+ async function uploadStream(conn, remotePath, data, onProgress, idleMs = STREAM_IDLE_TIMEOUT_MS) {
1450
1488
  const sendHttp = loadSendHttp();
1451
1489
  if (sendHttp === null) return null;
1490
+ const budget = transferBudget(data.length, idleMs);
1452
1491
  const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
1453
1492
  let lastError;
1454
1493
  for (const authMethod of candidates) try {
1455
- return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp);
1494
+ return await streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp);
1456
1495
  } catch (error) {
1457
1496
  if (error instanceof WinrmTimedOut) throw error;
1458
1497
  lastError = error;
@@ -1472,12 +1511,12 @@ const DOWNLOAD_RECEIVE_TIMEOUT_MS = 12e4;
1472
1511
  * @param start - first byte offset of this worker's range.
1473
1512
  * @param length - bytes this worker must deliver.
1474
1513
  * @param onData - receives each decompressed piece together with its file offset.
1475
- * @param timeoutMs - budget for the whole range.
1514
+ * @param budget - idle window and absolute backstop for this worker's range.
1476
1515
  * @param authMethod - the auth scheme for this attempt.
1477
1516
  * @param sendHttp - the resolved library HTTP function.
1478
1517
  * @returns the number of file bytes this worker delivered.
1479
1518
  */
1480
- async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeoutMs, authMethod, sendHttp) {
1519
+ async function downloadWorkerOnce(conn, remotePath, start, length, onData, budget, authMethod, sendHttp) {
1481
1520
  const base = baseParams(conn, authMethod);
1482
1521
  const target = {
1483
1522
  host: conn.host,
@@ -1489,7 +1528,9 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1489
1528
  useHttps: conn.useHttps ?? false,
1490
1529
  rejectUnauthorized: conn.rejectUnauthorized ?? true
1491
1530
  };
1492
- const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
1531
+ const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS;
1532
+ let lastDataAt = Date.now();
1533
+ const stalled = () => Date.now() - lastDataAt >= budget.idleMs;
1493
1534
  const state = {
1494
1535
  shellId: void 0,
1495
1536
  timedOut: false,
@@ -1524,7 +1565,7 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1524
1565
  let stdoutEnded = false;
1525
1566
  let exitCode;
1526
1567
  while (!stdoutEnded) {
1527
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
1568
+ if (state.settled || stalled()) throw new WinrmTimedOut();
1528
1569
  const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS);
1529
1570
  let sawData = false;
1530
1571
  for (const stream of result.streams) {
@@ -1538,6 +1579,7 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1538
1579
  if (stream.end) stdoutEnded = true;
1539
1580
  } else stderr += stream.data.toString("utf8");
1540
1581
  }
1582
+ if (sawData) lastDataAt = Date.now();
1541
1583
  if (inflateError !== void 0) throw inflateError;
1542
1584
  if (result.exitCode !== void 0) exitCode = result.exitCode;
1543
1585
  if (!sawData && exitCode !== void 0) break;
@@ -1586,15 +1628,16 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1586
1628
  * @param remotePath - file to read.
1587
1629
  * @param onData - receives each decompressed piece with its absolute file offset.
1588
1630
  * @param onProgress - receives cumulative bytes delivered and the total size.
1589
- * @param timeoutMs - budget for the whole download.
1631
+ * @param idleMs - how long a worker may go without delivering bytes.
1590
1632
  * @returns the number of bytes delivered, or null when streaming is unavailable.
1591
1633
  */
1592
- async function downloadStream(conn, remotePath, onData, onProgress, timeoutMs = 6e5) {
1634
+ async function downloadStream(conn, remotePath, onData, onProgress, idleMs = STREAM_IDLE_TIMEOUT_MS) {
1593
1635
  const sendHttp = loadSendHttp();
1594
1636
  if (sendHttp === null) return null;
1595
1637
  const total = await remoteFileSize(conn, remotePath);
1596
1638
  if (total === null) throw new Error(`remote file not found: ${remotePath}`);
1597
1639
  if (total === 0) return 0;
1640
+ const budget = transferBudget(total, idleMs);
1598
1641
  const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)));
1599
1642
  const rangeSize = Math.ceil(total / workers);
1600
1643
  let transferred = 0;
@@ -1618,7 +1661,7 @@ async function downloadStream(conn, remotePath, onData, onProgress, timeoutMs =
1618
1661
  start,
1619
1662
  length: Math.min(rangeSize, total - start)
1620
1663
  });
1621
- await Promise.all(ranges.map((range) => downloadWorkerOnce(conn, remotePath, range.start, range.length, report, timeoutMs, authMethod, sendHttp)));
1664
+ await Promise.all(ranges.map((range) => downloadWorkerOnce(conn, remotePath, range.start, range.length, report, budget, authMethod, sendHttp)));
1622
1665
  return transferred;
1623
1666
  } catch (error) {
1624
1667
  if (error instanceof WinrmTimedOut) throw error;
@@ -1781,7 +1824,7 @@ async function smbReachable(host) {
1781
1824
  }
1782
1825
  /** Error thrown when SMB was requested or probed and the target is not listening. */
1783
1826
  function smbUnavailable(host) {
1784
- return /* @__PURE__ */ new Error("SMB is not reachable on " + host + ":445 (the admin share needs TCP 445 open and the Server service running)");
1827
+ return /* @__PURE__ */ new Error("SMB is not reachable on " + host + ":445 (the admin share needs TCP 445 open and the Server service running); retry with channel \"winrm\" to transfer over WinRM instead");
1785
1828
  }
1786
1829
  /**
1787
1830
  * Copy one local file to a remote Windows host over SMB, verifying the
@@ -2430,16 +2473,19 @@ var WinRmEngine = class {
2430
2473
  percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
2431
2474
  });
2432
2475
  };
2476
+ let delivered = 0;
2433
2477
  try {
2434
2478
  const conn = connOf(entry);
2435
- if (await downloadStream(conn, remotePath, put, report) === null) {
2479
+ const streamed = await downloadStream(conn, remotePath, put, report);
2480
+ if (streamed === null) {
2436
2481
  let offset = 0;
2437
2482
  await downloadChunks(conn, remotePath, (b64) => {
2438
2483
  const buffer = Buffer.from(b64, "base64");
2439
2484
  put(offset, buffer);
2440
2485
  offset += buffer.length;
2441
2486
  }, report);
2442
- }
2487
+ delivered = bytes;
2488
+ } else delivered = streamed;
2443
2489
  await queue;
2444
2490
  if (sink === null) sink = await open(localPath, "w", 384);
2445
2491
  completed = true;
@@ -2451,12 +2497,12 @@ var WinRmEngine = class {
2451
2497
  onProgress?.({
2452
2498
  phase: "done",
2453
2499
  file: remotePath,
2454
- transferred: bytes,
2455
- total: bytes,
2500
+ transferred: delivered,
2501
+ total: delivered,
2456
2502
  percent: 100
2457
2503
  });
2458
2504
  return {
2459
- bytes,
2505
+ bytes: delivered,
2460
2506
  channel: "winrm"
2461
2507
  };
2462
2508
  });
@@ -3656,7 +3702,7 @@ function winrmUploadTool(engine) {
3656
3702
  "smb",
3657
3703
  "winrm"
3658
3704
  ],
3659
- description: "Transfer channel: smb (admin share), winrm (streamed over the WinRS stdin channel), or auto (default, SMB first with WinRM fallback)."
3705
+ description: "Transfer channel: auto (default) uses the SMB admin share when TCP 445 answers and otherwise WinRM; smb fails outright when 445 is closed; winrm always streams over the WinRS stdin channel."
3660
3706
  }
3661
3707
  },
3662
3708
  output: {
@@ -3700,7 +3746,7 @@ function winrmUploadTool(engine) {
3700
3746
  function winrmDownloadTool(engine) {
3701
3747
  return defineTool({
3702
3748
  name: "winrm_download",
3703
- 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.",
3749
+ 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). Large files are fine: the WinRM path runs for as long as bytes keep arriving and only fails once a range stalls. Directory download is not supported — download files individually. Triggers: download file from Windows server, fetch remote log/artifact.",
3704
3750
  parameters: {
3705
3751
  alias: {
3706
3752
  type: "string",
@@ -3724,7 +3770,7 @@ function winrmDownloadTool(engine) {
3724
3770
  "smb",
3725
3771
  "winrm"
3726
3772
  ],
3727
- description: "Transfer channel: smb (admin share), winrm (streamed over parallel WinRS ranges), or auto (default, SMB first with WinRM fallback)."
3773
+ description: "Transfer channel: auto (default) uses the SMB admin share when TCP 445 answers and otherwise WinRM; smb fails outright when 445 is closed; winrm always streams over parallel WinRS byte ranges."
3728
3774
  }
3729
3775
  },
3730
3776
  output: {
@@ -4258,7 +4304,7 @@ const DEFAULT_ENABLED = true;
4258
4304
  /** Order of the announcement section within the tool-guidance band. */
4259
4305
  const SECTION_ORDER = 151;
4260
4306
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
4261
- 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_read/winrm_write/winrm_edit 远程文本文件读写(按行分页、编码感知、精确串替换,作用于远端主机而非本机,本机文件仍用 read/write/edit)、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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
4307
+ 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_read/winrm_write/winrm_edit 远程文本文件读写(按行分页、编码感知、精确串替换,作用于远端主机而非本机,本机文件仍用 read/write/edit)、winrm_upload/winrm_download 文件传输(channel=auto 时若目标 445 可达走 SMB 管理共享,否则走 WinRM 流式传输;channel=smb 在 445 关闭时直接报错、需改用 winrm;大文件可下载,流式通道按字节持续推进、仅在字节断流时才判超时、完成后经 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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
4262
4308
  /**
4263
4309
  * Mount the WinRM engine, routes, tools, and announcement.
4264
4310
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -53,6 +53,32 @@ export declare const TRANSFER_CHUNK: number;
53
53
  export declare function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs?: number): Promise<number | null>;
54
54
  export declare function downloadChunks(conn: WinRMParams, remotePath: string, onChunk: (b64: string, bytes: number) => void, onProgress?: (transferred: number, total: number) => void, timeoutMs?: number): Promise<number>;
55
55
  export declare function uploadBuffer(conn: WinRMParams, remotePath: string, data: Buffer, onProgress?: (transferred: number, total: number) => void, timeoutMs?: number): Promise<number>;
56
+ /**
57
+ * How long a streaming transfer may deliver nothing before it is treated as
58
+ * stalled.
59
+ *
60
+ * A wall-clock cap on the whole transfer cannot work here: this transport is
61
+ * latency-bound and measured at ~0.73 MiB/s against a real target, so a 700 MiB
62
+ * file legitimately needs about sixteen minutes and any smaller fixed cap fails
63
+ * a transfer that is making steady progress. What a transfer actually needs
64
+ * bounding against is a *stall* — a range that has stopped producing bytes —
65
+ * which is what this idle window measures.
66
+ */
67
+ export declare const STREAM_IDLE_TIMEOUT_MS = 180000;
68
+ /**
69
+ * Budget for one streaming transfer of `bytes` bytes.
70
+ *
71
+ * The idle window is the effective limit; the hard window exists so a wedged
72
+ * transfer cannot stay alive forever, and it scales with the payload because
73
+ * the payload is what sets the legitimate duration.
74
+ * @param bytes - total bytes the transfer must move.
75
+ * @param idleMs - how long the transfer may go without delivering anything.
76
+ * @returns the idle window and the absolute backstop, both in milliseconds.
77
+ */
78
+ export declare function transferBudget(bytes: number, idleMs?: number): {
79
+ idleMs: number;
80
+ hardMs: number;
81
+ };
56
82
  /**
57
83
  * Upload a buffer over a single WinRS stdin stream.
58
84
  *
@@ -68,10 +94,10 @@ export declare function uploadBuffer(conn: WinRMParams, remotePath: string, data
68
94
  * @param remotePath - destination path on the target.
69
95
  * @param data - the file contents to send.
70
96
  * @param onProgress - called with cumulative bytes after each frame.
71
- * @param timeoutMs - whole-transfer budget.
97
+ * @param idleMs - how long the transfer may go without moving a slice.
72
98
  * @returns bytes written, or null when the streaming path is unavailable.
73
99
  */
74
- export declare function uploadStream(conn: WinRMParams, remotePath: string, data: Buffer, onProgress?: (transferred: number, total: number) => void, timeoutMs?: number): Promise<number | null>;
100
+ export declare function uploadStream(conn: WinRMParams, remotePath: string, data: Buffer, onProgress?: (transferred: number, total: number) => void, idleMs?: number): Promise<number | null>;
75
101
  /**
76
102
  * Stream a remote file to a consumer, in parallel byte ranges.
77
103
  *
@@ -90,7 +116,7 @@ export declare function uploadStream(conn: WinRMParams, remotePath: string, data
90
116
  * @param remotePath - file to read.
91
117
  * @param onData - receives each decompressed piece with its absolute file offset.
92
118
  * @param onProgress - receives cumulative bytes delivered and the total size.
93
- * @param timeoutMs - budget for the whole download.
119
+ * @param idleMs - how long a worker may go without delivering bytes.
94
120
  * @returns the number of bytes delivered, or null when streaming is unavailable.
95
121
  */
96
- export declare function downloadStream(conn: WinRMParams, remotePath: string, onData: (offset: number, data: Buffer) => void, onProgress?: (transferred: number, total: number) => void, timeoutMs?: number): Promise<number | null>;
122
+ export declare function downloadStream(conn: WinRMParams, remotePath: string, onData: (offset: number, data: Buffer) => void, onProgress?: (transferred: number, total: number) => void, idleMs?: number): Promise<number | null>;
@@ -35,7 +35,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
35
35
  enabled: z<boolean, boolean, "volatile-defined">;
36
36
  }>>, "plain">;
37
37
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
38
- export declare const WINRM_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-winrm \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B Windows \u8FD0\u7EF4\uFF0CWinRM/PowerShell Remoting\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CWindows\u300D\u5165\u53E3\uFF1B\u4EFF\u7167 dsh-ssh \u63D2\u4EF6\u5F00\u53D1\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 ~/.dsh/dsh-winrm.json\uFF08GUI \u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF09\uFF1Bwinrm_list \u5217\u51FA\u4E3B\u673A\u3001winrm_exec \u6267\u884C PowerShell \u547D\u4EE4\uFF08UTF-8 \u4FE1\u5C01\uFF0C\u4E2D\u6587\u4E0D\u4E71\u7801\uFF09\u3001winrm_service \u670D\u52A1\u7BA1\u7406\uFF08start/stop/restart/\u542F\u52A8\u7C7B\u578B\uFF09\u3001winrm_process \u8FDB\u7A0B\u7BA1\u7406\uFF08list/kill\uFF09\u3001winrm_read/winrm_write/winrm_edit \u8FDC\u7A0B\u6587\u672C\u6587\u4EF6\u8BFB\u5199\uFF08\u6309\u884C\u5206\u9875\u3001\u7F16\u7801\u611F\u77E5\u3001\u7CBE\u786E\u4E32\u66FF\u6362\uFF0C\u4F5C\u7528\u4E8E\u8FDC\u7AEF\u4E3B\u673A\u800C\u975E\u672C\u673A\uFF0C\u672C\u673A\u6587\u4EF6\u4ECD\u7528 read/write/edit\uFF09\u3001winrm_upload/winrm_download \u6587\u4EF6\u4F20\u8F93\uFF08SMB \u7BA1\u7406\u5171\u4EAB\u53EF\u8FBE\u65F6\u4F18\u5148\uFF0C\u5426\u5219\u8D70 WinRM \u6D41\u5F0F\u4F20\u8F93\uFF0C\u65E0 SMB \u4F9D\u8D56\uFF0C\u5927\u6587\u4EF6\u7ECF SHA256 \u6821\u9A8C\uFF09\u3001winrm_cluster \u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1BWeb \u63A7\u5236\u53F0\u8D70 WebSocket\u3002\u8BA4\u8BC1\uFF1A\u901A\u8FC7\u539F\u751F Node.js WinRM \u5BA2\u6237\u7AEF\uFF08winrm-client\uFF09\u7684 NTLM/Negotiate \u4E0E Basic \u5B9E\u73B0\uFF0C\u517C\u5BB9\u672C\u5730\u8D26\u6237\u4E0E\u57DF\u8D26\u6237\uFF1B\u4F20\u8F93\u652F\u6301 http(5985)/https(5986)\u3002\u524D\u63D0\uFF1A\u76EE\u6807\u673A\u9700\u542F\u7528 WinRM\uFF08scripts/enable-winrm.ps1\uFF09\uFF0C\u672C\u673A\u65E0\u9700 Python \u2014\u2014 WinRM \u8D70\u7EAF Node \u4F9D\u8D56\u3002HTTP \u4EC5\u5EFA\u8BAE\u53D7\u4FE1\u5185\u7F51\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u7528\u6237\u63D0\u5230\u300CWindows \u670D\u52A1\u5668 / WinRM / PowerShell \u8FDC\u7A0B / \u670D\u52A1\u7BA1\u7406 / \u8FDB\u7A0B\u7BA1\u7406\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
38
+ export declare const WINRM_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-winrm \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B Windows \u8FD0\u7EF4\uFF0CWinRM/PowerShell Remoting\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CWindows\u300D\u5165\u53E3\uFF1B\u4EFF\u7167 dsh-ssh \u63D2\u4EF6\u5F00\u53D1\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 ~/.dsh/dsh-winrm.json\uFF08GUI \u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF09\uFF1Bwinrm_list \u5217\u51FA\u4E3B\u673A\u3001winrm_exec \u6267\u884C PowerShell \u547D\u4EE4\uFF08UTF-8 \u4FE1\u5C01\uFF0C\u4E2D\u6587\u4E0D\u4E71\u7801\uFF09\u3001winrm_service \u670D\u52A1\u7BA1\u7406\uFF08start/stop/restart/\u542F\u52A8\u7C7B\u578B\uFF09\u3001winrm_process \u8FDB\u7A0B\u7BA1\u7406\uFF08list/kill\uFF09\u3001winrm_read/winrm_write/winrm_edit \u8FDC\u7A0B\u6587\u672C\u6587\u4EF6\u8BFB\u5199\uFF08\u6309\u884C\u5206\u9875\u3001\u7F16\u7801\u611F\u77E5\u3001\u7CBE\u786E\u4E32\u66FF\u6362\uFF0C\u4F5C\u7528\u4E8E\u8FDC\u7AEF\u4E3B\u673A\u800C\u975E\u672C\u673A\uFF0C\u672C\u673A\u6587\u4EF6\u4ECD\u7528 read/write/edit\uFF09\u3001winrm_upload/winrm_download \u6587\u4EF6\u4F20\u8F93\uFF08channel=auto \u65F6\u82E5\u76EE\u6807 445 \u53EF\u8FBE\u8D70 SMB \u7BA1\u7406\u5171\u4EAB\uFF0C\u5426\u5219\u8D70 WinRM \u6D41\u5F0F\u4F20\u8F93\uFF1Bchannel=smb \u5728 445 \u5173\u95ED\u65F6\u76F4\u63A5\u62A5\u9519\u3001\u9700\u6539\u7528 winrm\uFF1B\u5927\u6587\u4EF6\u53EF\u4E0B\u8F7D\uFF0C\u6D41\u5F0F\u901A\u9053\u6309\u5B57\u8282\u6301\u7EED\u63A8\u8FDB\u3001\u4EC5\u5728\u5B57\u8282\u65AD\u6D41\u65F6\u624D\u5224\u8D85\u65F6\u3001\u5B8C\u6210\u540E\u7ECF SHA256 \u6821\u9A8C\uFF09\u3001winrm_cluster \u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1BWeb \u63A7\u5236\u53F0\u8D70 WebSocket\u3002\u8BA4\u8BC1\uFF1A\u901A\u8FC7\u539F\u751F Node.js WinRM \u5BA2\u6237\u7AEF\uFF08winrm-client\uFF09\u7684 NTLM/Negotiate \u4E0E Basic \u5B9E\u73B0\uFF0C\u517C\u5BB9\u672C\u5730\u8D26\u6237\u4E0E\u57DF\u8D26\u6237\uFF1B\u4F20\u8F93\u652F\u6301 http(5985)/https(5986)\u3002\u524D\u63D0\uFF1A\u76EE\u6807\u673A\u9700\u542F\u7528 WinRM\uFF08scripts/enable-winrm.ps1\uFF09\uFF0C\u672C\u673A\u65E0\u9700 Python \u2014\u2014 WinRM \u8D70\u7EAF Node \u4F9D\u8D56\u3002HTTP \u4EC5\u5EFA\u8BAE\u53D7\u4FE1\u5185\u7F51\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u7528\u6237\u63D0\u5230\u300CWindows \u670D\u52A1\u5668 / WinRM / PowerShell \u8FDC\u7A0B / \u670D\u52A1\u7BA1\u7406 / \u8FDB\u7A0B\u7BA1\u7406\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
39
39
  /**
40
40
  * Mount the WinRM engine, routes, tools, and announcement.
41
41
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-winrm",
3
3
  "description": "Remote Windows administration for the dsh web GUI: WinRM/PowerShell Remoting host config store (~/.dsh/dsh-winrm.json), PowerShell exec / streaming console / service & process management / remote text read-write-edit / streamed file transfer / cluster execution, plus agent tools (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_read, winrm_write, winrm_edit, winrm_upload, winrm_download, winrm_cluster). Runs entirely on a native Node.js WinRM client (winrm-client) — no Python/pywinrm required. Standalone Cordis plugin — mounted via cordis.patch.yml + a profile node_modules link, no dsh source changes.",
4
- "version": "0.4.0",
4
+ "version": "0.4.1",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -337,6 +337,41 @@ const STREAM_GZIP_MIN_RATIO = 0.85
337
337
  /** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
338
338
  const STREAM_SEND_TIMEOUT_MS = 120_000
339
339
 
340
+ /**
341
+ * How long a streaming transfer may deliver nothing before it is treated as
342
+ * stalled.
343
+ *
344
+ * A wall-clock cap on the whole transfer cannot work here: this transport is
345
+ * latency-bound and measured at ~0.73 MiB/s against a real target, so a 700 MiB
346
+ * file legitimately needs about sixteen minutes and any smaller fixed cap fails
347
+ * a transfer that is making steady progress. What a transfer actually needs
348
+ * bounding against is a *stall* — a range that has stopped producing bytes —
349
+ * which is what this idle window measures.
350
+ */
351
+ export const STREAM_IDLE_TIMEOUT_MS = 180_000
352
+
353
+ /**
354
+ * Floor used only to size the runaway backstop, never as a throughput target.
355
+ * Deliberately pessimistic (64 KiB/s): it must not fail a slow link, only a
356
+ * transfer that is somehow still "busy" hours after it should have finished.
357
+ */
358
+ const STREAM_MIN_BYTES_PER_SEC = 65_536
359
+
360
+ /**
361
+ * Budget for one streaming transfer of `bytes` bytes.
362
+ *
363
+ * The idle window is the effective limit; the hard window exists so a wedged
364
+ * transfer cannot stay alive forever, and it scales with the payload because
365
+ * the payload is what sets the legitimate duration.
366
+ * @param bytes - total bytes the transfer must move.
367
+ * @param idleMs - how long the transfer may go without delivering anything.
368
+ * @returns the idle window and the absolute backstop, both in milliseconds.
369
+ */
370
+ export function transferBudget(bytes: number, idleMs = STREAM_IDLE_TIMEOUT_MS): { idleMs: number; hardMs: number } {
371
+ const optimisticMs = Math.ceil(Math.max(0, bytes) / STREAM_MIN_BYTES_PER_SEC) * 1000
372
+ return { idleMs, hardMs: Math.max(idleMs * 4, optimisticMs + idleMs) }
373
+ }
374
+
340
375
  /**
341
376
  * One streaming transfer over a single shell: create → run the receiver →
342
377
  * push the receiver script, the header frame and every data frame down stdin →
@@ -349,7 +384,7 @@ const STREAM_SEND_TIMEOUT_MS = 120_000
349
384
  * @param remotePath - destination path on the target.
350
385
  * @param data - the file contents to send.
351
386
  * @param onProgress - called with cumulative bytes after each frame.
352
- * @param timeoutMs - whole-transfer budget.
387
+ * @param budget - idle window and absolute backstop for the transfer.
353
388
  * @param authMethod - the auth scheme for this attempt.
354
389
  * @param sendHttp - the resolved library send function.
355
390
  * @returns the number of file bytes the receiver acknowledged writing.
@@ -360,7 +395,7 @@ async function streamOnce(
360
395
  remotePath: string,
361
396
  data: Buffer,
362
397
  onProgress: ((transferred: number, total: number) => void) | undefined,
363
- timeoutMs: number,
398
+ budget: { idleMs: number; hardMs: number },
364
399
  authMethod: WinrmAuth,
365
400
  sendHttp: ReturnType<typeof loadSendHttp> & object,
366
401
  ): Promise<number> {
@@ -375,7 +410,12 @@ async function streamOnce(
375
410
  useHttps: conn.useHttps ?? false,
376
411
  rejectUnauthorized: conn.rejectUnauthorized ?? true,
377
412
  }
378
- const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
413
+ const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS
414
+ // Progress is what keeps a transfer alive: the send loop and the final
415
+ // receive loop both refresh this, and only a window with no progress at all
416
+ // is treated as a stall.
417
+ let lastProgressAt = Date.now()
418
+ const stalled = (): boolean => Date.now() - lastProgressAt >= budget.idleMs
379
419
  const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
380
420
  const total = data.length
381
421
 
@@ -405,7 +445,7 @@ async function streamOnce(
405
445
  let slice = STREAM_CHUNK_BYTES
406
446
  let ceiling = STREAM_CHUNK_BYTES
407
447
  while (offset < total) {
408
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
448
+ if (state.settled || stalled()) throw new WinrmTimedOut()
409
449
  const take = Math.min(slice, total - offset)
410
450
  const chunk = data.subarray(offset, offset + take)
411
451
  const gz = gzipSync(chunk, { level: 6 })
@@ -422,6 +462,7 @@ async function streamOnce(
422
462
  throw error
423
463
  }
424
464
  offset += take
465
+ lastProgressAt = Date.now()
425
466
  onProgress?.(offset, total)
426
467
  if (slice < ceiling) slice = Math.min(ceiling, slice * 2)
427
468
  }
@@ -429,15 +470,15 @@ async function streamOnce(
429
470
  // 4. The receiver stops on its own once the declared byte count lands, so
430
471
  // there is nothing to close — just wait for its envelope.
431
472
  const receive = { ...base, shellId, commandId }
432
- const commandDeadline = Date.now() + timeoutMs
433
473
  let stdout = ''
434
474
  let stderr = ''
435
475
  for (;;) {
436
- if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut()
476
+ if (state.settled || stalled()) throw new WinrmTimedOut()
437
477
  const chunk = await Command.doReceiveOutputNonBlocking(receive)
438
478
  stdout += chunk.output
439
479
  stderr += chunk.stderr
440
480
  if (chunk.isComplete) break
481
+ if (chunk.output !== '' || chunk.stderr !== '') lastProgressAt = Date.now()
441
482
  }
442
483
 
443
484
  const parsed = parseEnvelope(stdout)
@@ -488,7 +529,7 @@ async function streamOnce(
488
529
  * @param remotePath - destination path on the target.
489
530
  * @param data - the file contents to send.
490
531
  * @param onProgress - called with cumulative bytes after each frame.
491
- * @param timeoutMs - whole-transfer budget.
532
+ * @param idleMs - how long the transfer may go without moving a slice.
492
533
  * @returns bytes written, or null when the streaming path is unavailable.
493
534
  */
494
535
  export async function uploadStream(
@@ -496,15 +537,16 @@ export async function uploadStream(
496
537
  remotePath: string,
497
538
  data: Buffer,
498
539
  onProgress?: (transferred: number, total: number) => void,
499
- timeoutMs = 600_000,
540
+ idleMs = STREAM_IDLE_TIMEOUT_MS,
500
541
  ): Promise<number | null> {
501
542
  const sendHttp = loadSendHttp()
502
543
  if (sendHttp === null) return null
544
+ const budget = transferBudget(data.length, idleMs)
503
545
  const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
504
546
  let lastError: unknown
505
547
  for (const authMethod of candidates) {
506
548
  try {
507
- return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp)
549
+ return await streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp)
508
550
  } catch (error) {
509
551
  if (error instanceof WinrmTimedOut) throw error
510
552
  lastError = error
@@ -527,7 +569,7 @@ const DOWNLOAD_RECEIVE_TIMEOUT_MS = 120_000
527
569
  * @param start - first byte offset of this worker's range.
528
570
  * @param length - bytes this worker must deliver.
529
571
  * @param onData - receives each decompressed piece together with its file offset.
530
- * @param timeoutMs - budget for the whole range.
572
+ * @param budget - idle window and absolute backstop for this worker's range.
531
573
  * @param authMethod - the auth scheme for this attempt.
532
574
  * @param sendHttp - the resolved library HTTP function.
533
575
  * @returns the number of file bytes this worker delivered.
@@ -538,7 +580,7 @@ async function downloadWorkerOnce(
538
580
  start: number,
539
581
  length: number,
540
582
  onData: (offset: number, data: Buffer) => void,
541
- timeoutMs: number,
583
+ budget: { idleMs: number; hardMs: number },
542
584
  authMethod: WinrmAuth,
543
585
  sendHttp: ReturnType<typeof loadSendHttp> & object,
544
586
  ): Promise<number> {
@@ -553,7 +595,12 @@ async function downloadWorkerOnce(
553
595
  useHttps: conn.useHttps ?? false,
554
596
  rejectUnauthorized: conn.rejectUnauthorized ?? true,
555
597
  }
556
- const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
598
+ const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS
599
+ // Same rule as the upload path: bytes arriving keep the worker alive, and
600
+ // only a window with no bytes at all counts as a stall. A flat whole-transfer
601
+ // cap would fail a large file on a slow link even while it is moving.
602
+ let lastDataAt = Date.now()
603
+ const stalled = (): boolean => Date.now() - lastDataAt >= budget.idleMs
557
604
  const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
558
605
 
559
606
  const inner = async (): Promise<number> => {
@@ -589,7 +636,7 @@ async function downloadWorkerOnce(
589
636
  // A worker that lost the deadline race keeps running in the background
590
637
  // unless it checks; without this it would go on fetching and pushing
591
638
  // bytes into a file the caller has already given up on.
592
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
639
+ if (state.settled || stalled()) throw new WinrmTimedOut()
593
640
  const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS)
594
641
  let sawData = false
595
642
  for (const stream of result.streams) {
@@ -605,6 +652,7 @@ async function downloadWorkerOnce(
605
652
  stderr += stream.data.toString('utf8')
606
653
  }
607
654
  }
655
+ if (sawData) lastDataAt = Date.now()
608
656
  if (inflateError !== undefined) throw inflateError
609
657
  if (result.exitCode !== undefined) exitCode = result.exitCode
610
658
  // A finished command whose final response carried nothing means the
@@ -663,7 +711,7 @@ async function downloadWorkerOnce(
663
711
  * @param remotePath - file to read.
664
712
  * @param onData - receives each decompressed piece with its absolute file offset.
665
713
  * @param onProgress - receives cumulative bytes delivered and the total size.
666
- * @param timeoutMs - budget for the whole download.
714
+ * @param idleMs - how long a worker may go without delivering bytes.
667
715
  * @returns the number of bytes delivered, or null when streaming is unavailable.
668
716
  */
669
717
  export async function downloadStream(
@@ -671,13 +719,15 @@ export async function downloadStream(
671
719
  remotePath: string,
672
720
  onData: (offset: number, data: Buffer) => void,
673
721
  onProgress?: (transferred: number, total: number) => void,
674
- timeoutMs = 600_000,
722
+ idleMs = STREAM_IDLE_TIMEOUT_MS,
675
723
  ): Promise<number | null> {
676
724
  const sendHttp = loadSendHttp()
677
725
  if (sendHttp === null) return null
678
726
  const total = await remoteFileSize(conn, remotePath)
679
727
  if (total === null) throw new Error(`remote file not found: ${remotePath}`)
680
728
  if (total === 0) return 0
729
+ // The payload size, not a constant, is what sets a legitimate duration here.
730
+ const budget = transferBudget(total, idleMs)
681
731
  const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)))
682
732
  const rangeSize = Math.ceil(total / workers)
683
733
  let transferred = 0
@@ -708,7 +758,7 @@ export async function downloadStream(
708
758
  }
709
759
  await Promise.all(
710
760
  ranges.map(range =>
711
- downloadWorkerOnce(conn, remotePath, range.start, range.length, report, timeoutMs, authMethod, sendHttp),
761
+ downloadWorkerOnce(conn, remotePath, range.start, range.length, report, budget, authMethod, sendHttp),
712
762
  ),
713
763
  )
714
764
  return transferred
package/src/engine.ts CHANGED
@@ -536,6 +536,7 @@ export class WinRmEngine {
536
536
  const report = (transferred: number, total: number): void => {
537
537
  onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
538
538
  }
539
+ let delivered = 0
539
540
  try {
540
541
  const conn = connOf(entry)
541
542
  const streamed = await downloadStream(conn, remotePath, put, report)
@@ -551,6 +552,12 @@ export class WinRmEngine {
551
552
  },
552
553
  report,
553
554
  )
555
+ delivered = bytes
556
+ } else {
557
+ // `bytes` counts every positional write, and a retry re-sends the
558
+ // ranges from their start — so it would report more than the file
559
+ // holds. The successful attempt's own total is the file size.
560
+ delivered = streamed
554
561
  }
555
562
  await queue
556
563
  // A zero-byte remote file produces no writes, so the sink is still
@@ -570,8 +577,8 @@ export class WinRmEngine {
570
577
  // already truncated that file to nothing.
571
578
  if (!completed && sink !== null) await rm(localPath, { force: true }).catch(() => undefined)
572
579
  }
573
- onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
574
- return { bytes, channel: 'winrm' }
580
+ onProgress?.({ phase: 'done', file: remotePath, transferred: delivered, total: delivered, percent: 100 })
581
+ return { bytes: delivered, channel: 'winrm' }
575
582
  })
576
583
  }
577
584
 
package/src/index.ts CHANGED
@@ -68,7 +68,7 @@ const DEFAULT_ENABLED = true
68
68
  const SECTION_ORDER = 151
69
69
 
70
70
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
71
- export 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_read/winrm_write/winrm_edit 远程文本文件读写(按行分页、编码感知、精确串替换,作用于远端主机而非本机,本机文件仍用 read/write/edit)、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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。'
71
+ export 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_read/winrm_write/winrm_edit 远程文本文件读写(按行分页、编码感知、精确串替换,作用于远端主机而非本机,本机文件仍用 read/write/edit)、winrm_upload/winrm_download 文件传输(channel=auto 时若目标 445 可达走 SMB 管理共享,否则走 WinRM 流式传输;channel=smb 在 445 关闭时直接报错、需改用 winrm;大文件可下载,流式通道按字节持续推进、仅在字节断流时才判超时、完成后经 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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。'
72
72
 
73
73
  /**
74
74
  * Mount the WinRM engine, routes, tools, and announcement.
package/src/smb.ts CHANGED
@@ -134,7 +134,10 @@ export async function smbReachable(host: string): Promise<boolean> {
134
134
 
135
135
  /** Error thrown when SMB was requested or probed and the target is not listening. */
136
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)')
137
+ return new Error(
138
+ 'SMB is not reachable on ' + host + ':445 (the admin share needs TCP 445 open and the Server service running); '
139
+ + 'retry with channel "winrm" to transfer over WinRM instead',
140
+ )
138
141
  }
139
142
 
140
143
  /**
package/src/tools.ts CHANGED
@@ -273,7 +273,7 @@ export function winrmUploadTool(engine: WinRmEngine) {
273
273
  alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
274
274
  localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
275
275
  remotePath: { type: 'string', required: true, description: 'Destination path on the remote host, e.g. C:\\temp\\file.txt (parent dirs are created).' },
276
- 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
+ channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: auto (default) uses the SMB admin share when TCP 445 answers and otherwise WinRM; smb fails outright when 445 is closed; winrm always streams over the WinRS stdin channel.' },
277
277
  },
278
278
  output: {
279
279
  schema: {
@@ -306,13 +306,13 @@ export function winrmUploadTool(engine: WinRmEngine) {
306
306
  export function winrmDownloadTool(engine: WinRmEngine) {
307
307
  return defineTool({
308
308
  name: 'winrm_download',
309
- 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. ' +
309
+ 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). Large files are fine: the WinRM path runs for as long as bytes keep arriving and only fails once a range stalls. Directory download is not supported — download files individually. ' +
310
310
  'Triggers: download file from Windows server, fetch remote log/artifact.',
311
311
  parameters: {
312
312
  alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
313
313
  remotePath: { type: 'string', required: true, description: 'Remote file path, e.g. C:\\temp\\file.txt.' },
314
314
  localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
315
- 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
+ channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: auto (default) uses the SMB admin share when TCP 445 answers and otherwise WinRM; smb fails outright when 445 is closed; winrm always streams over parallel WinRS byte ranges.' },
316
316
  },
317
317
  output: {
318
318
  schema: {