dsh-winrm 0.3.1 → 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/lib/index.js CHANGED
@@ -322,6 +322,243 @@ function psWriteChunk(p, b64, append) {
322
322
  ""
323
323
  ].join("\r\n");
324
324
  }
325
+ /**
326
+ * JSON: one bounded, line-numbered window of a remote TEXT file.
327
+ *
328
+ * Decodes UTF-8 (with or without BOM), UTF-16LE and UTF-16BE by BOM, and
329
+ * rejects invalid UTF-8 instead of returning mojibake — a binary file fails
330
+ * loudly rather than as unreadable text. Lines are numbered from 1 over the
331
+ * whole file, `offset` selects the first returned line, and the window stops
332
+ * early once the joined text would exceed `maxBytes` (reported as
333
+ * `truncated`, so the caller knows more lines remain). A line longer than
334
+ * `maxLineLength` is cut to `maxLineLength + 1` code units here; the Node half
335
+ * appends the truncation note.
336
+ *
337
+ * A missing path or a directory emits `{"exists":false}`; anything the reader
338
+ * rejects emits `{"error":…}`. Both keep the exit code at 0 so the caller
339
+ * always receives parseable JSON rather than a PowerShell error record.
340
+ * @param p - remote path.
341
+ * @param offset - 1-based first line to return.
342
+ * @param limit - maximum number of lines to return.
343
+ * @param maxLineLength - maximum characters kept per line.
344
+ * @param maxBytes - maximum UTF-8 bytes of joined line text.
345
+ * @returns the PowerShell fragment.
346
+ */
347
+ function psReadTextWindow(p, offset, limit, maxLineLength, maxBytes) {
348
+ return [
349
+ "$__p = " + sq(p),
350
+ "$__err = $null",
351
+ "$__json = $null",
352
+ "try {",
353
+ " $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue",
354
+ " if ($null -eq $__i -or $__i.PSIsContainer) {",
355
+ " $__json = [pscustomobject]@{ exists = $false } | ConvertTo-Json -Compress",
356
+ " } else {",
357
+ " $__fs = [System.IO.File]::OpenRead($__p)",
358
+ " try {",
359
+ " $__b0 = New-Object byte[] 3",
360
+ " $__n0 = $__fs.Read($__b0, 0, 3)",
361
+ " $__fs.Position = 0",
362
+ " $__enc = 'utf8'",
363
+ " $__coding = New-Object System.Text.UTF8Encoding($false, $true)",
364
+ " if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {",
365
+ " $__enc = 'utf16le'",
366
+ " $__coding = [System.Text.Encoding]::Unicode",
367
+ " } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {",
368
+ " $__enc = 'utf16be'",
369
+ " $__coding = [System.Text.Encoding]::BigEndianUnicode",
370
+ " } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {",
371
+ " $__enc = 'utf8bom'",
372
+ " }",
373
+ " $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)",
374
+ " try {",
375
+ " $__total = 0",
376
+ " $__sel = New-Object System.Collections.ArrayList",
377
+ " $__used = 0",
378
+ " $__cut = $false",
379
+ " $__bytes = $false",
380
+ " while ($null -ne ($__line = $__r.ReadLine())) {",
381
+ " $__total++",
382
+ " if ($__cut) { continue }",
383
+ " if ($__total -lt " + String(offset) + ") { continue }",
384
+ " if ($__sel.Count -ge " + String(limit) + ") { $__cut = $true; continue }",
385
+ " $__t = $__line",
386
+ " if ($__t.Length -gt " + String(maxLineLength + 1) + ") { $__t = $__t.Substring(0, " + String(maxLineLength + 1) + ") }",
387
+ " $__b = [System.Text.Encoding]::UTF8.GetByteCount($__t)",
388
+ " if ($__sel.Count -gt 0) { $__b++ }",
389
+ " if ($__used + $__b -gt " + String(maxBytes) + ") { $__cut = $true; $__bytes = $true; continue }",
390
+ " $__used += $__b",
391
+ " [void]$__sel.Add($__t)",
392
+ " }",
393
+ " } finally { $__r.Dispose() }",
394
+ " } finally { $__fs.Dispose() }",
395
+ " $__json = [pscustomobject]@{",
396
+ " exists = $true",
397
+ " size = [long]$__i.Length",
398
+ " encoding = $__enc",
399
+ " totalLines = $__total",
400
+ " lineCount = $__sel.Count",
401
+ " truncated = $__cut",
402
+ " truncatedByBytes = $__bytes",
403
+ " linesText = (($__sel | ForEach-Object { [string]$_ }) -join \"`n\")",
404
+ " } | ConvertTo-Json -Compress -Depth 3",
405
+ " }",
406
+ "} catch { $__err = $_.Exception.GetBaseException().Message }",
407
+ "if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }",
408
+ "else { $__json }",
409
+ ""
410
+ ].join("\r\n");
411
+ }
412
+ /**
413
+ * JSON: replace `oldB64` with `newB64` (both UTF-8, base64-carried) in a remote
414
+ * text file, preserving its encoding and writing through the same file.
415
+ *
416
+ * The search is ordinal and never pattern-based, so a replacement cannot be
417
+ * reinterpreted as a regular expression. Without `replaceAll` the edit is
418
+ * refused unless the needle appears exactly once, which is what keeps a stale
419
+ * or too-short needle from rewriting the wrong region. The file is read whole,
420
+ * so this is meant for source files and configuration, not for multi-hundred-
421
+ * megabyte logs.
422
+ *
423
+ * Failures (missing file, absent needle, ambiguous needle, undecodable bytes)
424
+ * are reported as `{"error":…}` with exit code 0 so the caller always receives
425
+ * parseable JSON.
426
+ * @param p - remote path.
427
+ * @param oldB64 - base64 of the UTF-8 text to find.
428
+ * @param newB64 - base64 of the UTF-8 text to substitute.
429
+ * @param replaceAll - replace every occurrence instead of requiring exactly one.
430
+ * @param basisMaxBytes - include the before/after text when the original file is
431
+ * at most this large; `0` omits the basis entirely.
432
+ * @returns the PowerShell fragment.
433
+ */
434
+ function psEditText(p, oldB64, newB64, replaceAll, basisMaxBytes = 0) {
435
+ const branch = replaceAll ? [
436
+ " $__count = [int](($__text.Length - $__text.Replace($__old, [string]::Empty).Length) / $__old.Length)",
437
+ " if ($__count -eq 0) { throw ('oldString was not found in ' + $__p) }",
438
+ " $__out = $__text.Replace($__old, $__new)"
439
+ ] : [
440
+ " $__at = $__text.IndexOf($__old, [System.StringComparison]::Ordinal)",
441
+ " if ($__at -lt 0) { throw ('oldString was not found in ' + $__p) }",
442
+ " if ($__text.IndexOf($__old, $__at + 1, [System.StringComparison]::Ordinal) -ge 0) {",
443
+ " throw ('oldString appears more than once in ' + $__p + '; pass replaceAll=true or include more surrounding context')",
444
+ " }",
445
+ " $__out = $__text.Substring(0, $__at) + $__new + $__text.Substring($__at + $__old.Length)",
446
+ " $__count = 1"
447
+ ];
448
+ const basisGuard = basisMaxBytes > 0 ? `[long]$__i.Length -le ${basisMaxBytes}` : "$false";
449
+ return [
450
+ "$__p = " + sq(p),
451
+ "$__err = $null",
452
+ "$__json = $null",
453
+ "try {",
454
+ " $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue",
455
+ " if ($null -eq $__i -or $__i.PSIsContainer) { throw ('remote file not found: ' + $__p) }",
456
+ " $__old = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(" + sq(oldB64) + "))",
457
+ " $__new = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(" + sq(newB64) + "))",
458
+ " if ($__old.Length -eq 0) { throw " + sq("oldString must be a non-empty string") + " }",
459
+ " $__fs = [System.IO.File]::OpenRead($__p)",
460
+ " try {",
461
+ " $__b0 = New-Object byte[] 3",
462
+ " $__n0 = $__fs.Read($__b0, 0, 3)",
463
+ " $__fs.Position = 0",
464
+ " $__enc = 'utf8'",
465
+ " $__coding = New-Object System.Text.UTF8Encoding($false, $true)",
466
+ " if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {",
467
+ " $__enc = 'utf16le'",
468
+ " $__coding = [System.Text.Encoding]::Unicode",
469
+ " } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {",
470
+ " $__enc = 'utf16be'",
471
+ " $__coding = [System.Text.Encoding]::BigEndianUnicode",
472
+ " } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {",
473
+ " $__enc = 'utf8bom'",
474
+ " }",
475
+ " $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)",
476
+ " try { $__text = $__r.ReadToEnd() } finally { $__r.Dispose() }",
477
+ " } finally { $__fs.Dispose() }",
478
+ ...branch,
479
+ " if ($__enc -eq \"utf8bom\") { [System.IO.File]::WriteAllText($__p, $__out, (New-Object System.Text.UTF8Encoding($true))) }",
480
+ " elseif ($__enc -eq \"utf16le\") { [System.IO.File]::WriteAllText($__p, $__out, [System.Text.Encoding]::Unicode) }",
481
+ " elseif ($__enc -eq \"utf16be\") { [System.IO.File]::WriteAllText($__p, $__out, [System.Text.Encoding]::BigEndianUnicode) }",
482
+ " else { [System.IO.File]::WriteAllText($__p, $__out, (New-Object System.Text.UTF8Encoding($false))) }",
483
+ " $__basis = $null",
484
+ " if (" + basisGuard + ") {",
485
+ " $__basis = [pscustomobject]@{",
486
+ " before = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__text))",
487
+ " after = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__out))",
488
+ " }",
489
+ " }",
490
+ " $__json = [pscustomobject]@{",
491
+ " path = $__p",
492
+ " encoding = $__enc",
493
+ " replacements = $__count",
494
+ " size = [long](Get-Item -LiteralPath $__p).Length",
495
+ " basis = $__basis",
496
+ " } | ConvertTo-Json -Compress -Depth 3",
497
+ "} catch { $__err = $_.Exception.GetBaseException().Message }",
498
+ "if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }",
499
+ "else { $__json }",
500
+ ""
501
+ ].join("\r\n");
502
+ }
503
+ /**
504
+ * JSON: the whole remote text file as base64, or a refusal when it is missing
505
+ * or larger than `maxBytes`.
506
+ *
507
+ * The write tool needs the previous text to project a diff card, and the line
508
+ * window reader cannot serve that: it numbers lines and caps each one, so a
509
+ * long line would silently truncate inside the diff basis. The BOM detection
510
+ * repeats {@link psEditText}'s on purpose — both must decode identically, and a
511
+ * shared emitter would hide that requirement behind a helper neither could be
512
+ * reviewed against.
513
+ *
514
+ * Failures are reported as `{"error":…}` with exit code 0, so the caller always
515
+ * receives parseable JSON.
516
+ * @param p - remote path.
517
+ * @param maxBytes - largest file whose text is returned.
518
+ * @returns the PowerShell fragment.
519
+ */
520
+ function psReadTextBasis(p, maxBytes) {
521
+ return [
522
+ "$__p = " + sq(p),
523
+ "$__err = $null",
524
+ "$__json = $null",
525
+ "try {",
526
+ " $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue",
527
+ " if ($null -eq $__i -or $__i.PSIsContainer) { throw ('remote file not found: ' + $__p) }",
528
+ ` if ([long]$__i.Length -gt ${maxBytes}) {`,
529
+ " $__json = [pscustomobject]@{ tooLarge = $true; encoding = $null; text = $null } | ConvertTo-Json -Compress",
530
+ " } else {",
531
+ " $__fs = [System.IO.File]::OpenRead($__p)",
532
+ " try {",
533
+ " $__b0 = New-Object byte[] 3",
534
+ " $__n0 = $__fs.Read($__b0, 0, 3)",
535
+ " $__fs.Position = 0",
536
+ " $__enc = 'utf8'",
537
+ " $__coding = New-Object System.Text.UTF8Encoding($false, $true)",
538
+ " if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {",
539
+ " $__enc = 'utf16le'",
540
+ " $__coding = [System.Text.Encoding]::Unicode",
541
+ " } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {",
542
+ " $__enc = 'utf16be'",
543
+ " $__coding = [System.Text.Encoding]::BigEndianUnicode",
544
+ " } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {",
545
+ " $__enc = 'utf8bom'",
546
+ " }",
547
+ " $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)",
548
+ " try { $__text = $__r.ReadToEnd() } finally { $__r.Dispose() }",
549
+ " } finally { $__fs.Dispose() }",
550
+ " $__json = [pscustomobject]@{",
551
+ " tooLarge = $false",
552
+ " encoding = $__enc",
553
+ " text = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__text))",
554
+ " } | ConvertTo-Json -Compress -Depth 3",
555
+ " }",
556
+ "} catch { $__err = $_.Exception.GetBaseException().Message }",
557
+ "if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }",
558
+ "else { $__json }",
559
+ ""
560
+ ].join("\r\n");
561
+ }
325
562
  /** Magic stamped into the frame header that precedes every stream chunk. */
326
563
  const STREAM_FRAME_MAGIC = "DSHZ";
327
564
  /** Remote-side buffer used while draining stdin (or inflating a frame) into the file. */
@@ -1070,6 +1307,41 @@ const STREAM_GZIP_MIN_RATIO = .85;
1070
1307
  /** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
1071
1308
  const STREAM_SEND_TIMEOUT_MS = 12e4;
1072
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
+ /**
1073
1345
  * One streaming transfer over a single shell: create → run the receiver →
1074
1346
  * push the receiver script, the header frame and every data frame down stdin →
1075
1347
  * receive the envelope → delete the shell.
@@ -1081,13 +1353,13 @@ const STREAM_SEND_TIMEOUT_MS = 12e4;
1081
1353
  * @param remotePath - destination path on the target.
1082
1354
  * @param data - the file contents to send.
1083
1355
  * @param onProgress - called with cumulative bytes after each frame.
1084
- * @param timeoutMs - whole-transfer budget.
1356
+ * @param budget - idle window and absolute backstop for the transfer.
1085
1357
  * @param authMethod - the auth scheme for this attempt.
1086
1358
  * @param sendHttp - the resolved library send function.
1087
1359
  * @returns the number of file bytes the receiver acknowledged writing.
1088
1360
  * @throws WinrmTimedOut when the deadline passes; other errors are transport failures.
1089
1361
  */
1090
- async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp) {
1362
+ async function streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp) {
1091
1363
  const base = baseParams(conn, authMethod);
1092
1364
  const target = {
1093
1365
  host: conn.host,
@@ -1099,7 +1371,9 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1099
1371
  useHttps: conn.useHttps ?? false,
1100
1372
  rejectUnauthorized: conn.rejectUnauthorized ?? true
1101
1373
  };
1102
- 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;
1103
1377
  const state = {
1104
1378
  shellId: void 0,
1105
1379
  timedOut: false,
@@ -1128,7 +1402,7 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1128
1402
  let slice = STREAM_CHUNK_BYTES;
1129
1403
  let ceiling = STREAM_CHUNK_BYTES;
1130
1404
  while (offset < total) {
1131
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
1405
+ if (state.settled || stalled()) throw new WinrmTimedOut();
1132
1406
  const take = Math.min(slice, total - offset);
1133
1407
  const chunk = data.subarray(offset, offset + take);
1134
1408
  const gz = gzipSync(chunk, { level: 6 });
@@ -1145,6 +1419,7 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1145
1419
  throw error;
1146
1420
  }
1147
1421
  offset += take;
1422
+ lastProgressAt = Date.now();
1148
1423
  onProgress?.(offset, total);
1149
1424
  if (slice < ceiling) slice = Math.min(ceiling, slice * 2);
1150
1425
  }
@@ -1153,15 +1428,15 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1153
1428
  shellId,
1154
1429
  commandId
1155
1430
  };
1156
- const commandDeadline = Date.now() + timeoutMs;
1157
1431
  let stdout = "";
1158
1432
  let stderr = "";
1159
1433
  for (;;) {
1160
- if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut();
1434
+ if (state.settled || stalled()) throw new WinrmTimedOut();
1161
1435
  const chunk = await Command.doReceiveOutputNonBlocking(receive);
1162
1436
  stdout += chunk.output;
1163
1437
  stderr += chunk.stderr;
1164
1438
  if (chunk.isComplete) break;
1439
+ if (chunk.output !== "" || chunk.stderr !== "") lastProgressAt = Date.now();
1165
1440
  }
1166
1441
  const parsed = parseEnvelope(stdout);
1167
1442
  if (parsed === null) throw new Error("streaming upload did not report completion: " + (stripClixml(stderr) || stdout).trim());
@@ -1206,16 +1481,17 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
1206
1481
  * @param remotePath - destination path on the target.
1207
1482
  * @param data - the file contents to send.
1208
1483
  * @param onProgress - called with cumulative bytes after each frame.
1209
- * @param timeoutMs - whole-transfer budget.
1484
+ * @param idleMs - how long the transfer may go without moving a slice.
1210
1485
  * @returns bytes written, or null when the streaming path is unavailable.
1211
1486
  */
1212
- async function uploadStream(conn, remotePath, data, onProgress, timeoutMs = 6e5) {
1487
+ async function uploadStream(conn, remotePath, data, onProgress, idleMs = STREAM_IDLE_TIMEOUT_MS) {
1213
1488
  const sendHttp = loadSendHttp();
1214
1489
  if (sendHttp === null) return null;
1490
+ const budget = transferBudget(data.length, idleMs);
1215
1491
  const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
1216
1492
  let lastError;
1217
1493
  for (const authMethod of candidates) try {
1218
- return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp);
1494
+ return await streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp);
1219
1495
  } catch (error) {
1220
1496
  if (error instanceof WinrmTimedOut) throw error;
1221
1497
  lastError = error;
@@ -1235,12 +1511,12 @@ const DOWNLOAD_RECEIVE_TIMEOUT_MS = 12e4;
1235
1511
  * @param start - first byte offset of this worker's range.
1236
1512
  * @param length - bytes this worker must deliver.
1237
1513
  * @param onData - receives each decompressed piece together with its file offset.
1238
- * @param timeoutMs - budget for the whole range.
1514
+ * @param budget - idle window and absolute backstop for this worker's range.
1239
1515
  * @param authMethod - the auth scheme for this attempt.
1240
1516
  * @param sendHttp - the resolved library HTTP function.
1241
1517
  * @returns the number of file bytes this worker delivered.
1242
1518
  */
1243
- async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeoutMs, authMethod, sendHttp) {
1519
+ async function downloadWorkerOnce(conn, remotePath, start, length, onData, budget, authMethod, sendHttp) {
1244
1520
  const base = baseParams(conn, authMethod);
1245
1521
  const target = {
1246
1522
  host: conn.host,
@@ -1252,7 +1528,9 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1252
1528
  useHttps: conn.useHttps ?? false,
1253
1529
  rejectUnauthorized: conn.rejectUnauthorized ?? true
1254
1530
  };
1255
- 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;
1256
1534
  const state = {
1257
1535
  shellId: void 0,
1258
1536
  timedOut: false,
@@ -1287,7 +1565,7 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1287
1565
  let stdoutEnded = false;
1288
1566
  let exitCode;
1289
1567
  while (!stdoutEnded) {
1290
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
1568
+ if (state.settled || stalled()) throw new WinrmTimedOut();
1291
1569
  const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS);
1292
1570
  let sawData = false;
1293
1571
  for (const stream of result.streams) {
@@ -1301,6 +1579,7 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1301
1579
  if (stream.end) stdoutEnded = true;
1302
1580
  } else stderr += stream.data.toString("utf8");
1303
1581
  }
1582
+ if (sawData) lastDataAt = Date.now();
1304
1583
  if (inflateError !== void 0) throw inflateError;
1305
1584
  if (result.exitCode !== void 0) exitCode = result.exitCode;
1306
1585
  if (!sawData && exitCode !== void 0) break;
@@ -1349,15 +1628,16 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
1349
1628
  * @param remotePath - file to read.
1350
1629
  * @param onData - receives each decompressed piece with its absolute file offset.
1351
1630
  * @param onProgress - receives cumulative bytes delivered and the total size.
1352
- * @param timeoutMs - budget for the whole download.
1631
+ * @param idleMs - how long a worker may go without delivering bytes.
1353
1632
  * @returns the number of bytes delivered, or null when streaming is unavailable.
1354
1633
  */
1355
- async function downloadStream(conn, remotePath, onData, onProgress, timeoutMs = 6e5) {
1634
+ async function downloadStream(conn, remotePath, onData, onProgress, idleMs = STREAM_IDLE_TIMEOUT_MS) {
1356
1635
  const sendHttp = loadSendHttp();
1357
1636
  if (sendHttp === null) return null;
1358
1637
  const total = await remoteFileSize(conn, remotePath);
1359
1638
  if (total === null) throw new Error(`remote file not found: ${remotePath}`);
1360
1639
  if (total === 0) return 0;
1640
+ const budget = transferBudget(total, idleMs);
1361
1641
  const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)));
1362
1642
  const rangeSize = Math.ceil(total / workers);
1363
1643
  let transferred = 0;
@@ -1381,7 +1661,7 @@ async function downloadStream(conn, remotePath, onData, onProgress, timeoutMs =
1381
1661
  start,
1382
1662
  length: Math.min(rangeSize, total - start)
1383
1663
  });
1384
- 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)));
1385
1665
  return transferred;
1386
1666
  } catch (error) {
1387
1667
  if (error instanceof WinrmTimedOut) throw error;
@@ -1544,7 +1824,7 @@ async function smbReachable(host) {
1544
1824
  }
1545
1825
  /** Error thrown when SMB was requested or probed and the target is not listening. */
1546
1826
  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)");
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");
1548
1828
  }
1549
1829
  /**
1550
1830
  * Copy one local file to a remote Windows host over SMB, verifying the
@@ -1663,14 +1943,125 @@ var ConsoleSession = class {
1663
1943
  this.onExit?.(code, error);
1664
1944
  }
1665
1945
  };
1946
+ /**
1947
+ * Largest file whose text may serve as a diff basis. Above this the card shows
1948
+ * the path and operation without hunks: fetching and projecting a bigger basis
1949
+ * would cost another transfer and a large metadata payload for a summary the
1950
+ * user reads at a glance.
1951
+ */
1952
+ const DIFF_BASIS_MAX_BYTES = 256 * 1024;
1953
+ /** Split text into lines, treating every terminator as a separator. */
1954
+ function toLines(text) {
1955
+ return text === "" ? [] : text.split(/\r?\n/);
1956
+ }
1957
+ /**
1958
+ * Compute the hunks between two texts.
1959
+ * @param path - remote path stamped on every hunk.
1960
+ * @param before - file text before the change.
1961
+ * @param after - file text after the change.
1962
+ * @returns one hunk per contiguous change in file order; empty when equal.
1963
+ */
1964
+ function computeHunkDiffs(path, before, after) {
1965
+ if (before === after) return [];
1966
+ const oldLines = toLines(before);
1967
+ const newLines = toLines(after);
1968
+ let head = 0;
1969
+ while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head += 1;
1970
+ let tail = 0;
1971
+ while (tail < oldLines.length - head && tail < newLines.length - head && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]) tail += 1;
1972
+ const oldMid = oldLines.slice(head, oldLines.length - tail);
1973
+ const newMid = newLines.slice(head, newLines.length - tail);
1974
+ /** Build one hunk around the changed range `[from, to)` in the middle. */
1975
+ const emit = (from, to) => {
1976
+ const start = Math.max(0, from - 3);
1977
+ const stop = Math.min(oldMid.length, to + 3);
1978
+ return {
1979
+ path,
1980
+ oldText: oldLines.slice(head + start, head + stop).join("\n"),
1981
+ newText: newLines.slice(head + start, head + stop).join("\n")
1982
+ };
1983
+ };
1984
+ if (oldMid.length !== newMid.length) return [{
1985
+ path,
1986
+ oldText: oldLines.slice(Math.max(0, head - 3), oldLines.length - Math.max(0, tail - 3)).join("\n"),
1987
+ newText: newLines.slice(Math.max(0, head - 3), newLines.length - Math.max(0, tail - 3)).join("\n")
1988
+ }];
1989
+ const diffs = [];
1990
+ let index = 0;
1991
+ while (index < oldMid.length) {
1992
+ if (oldMid[index] === newMid[index]) {
1993
+ index += 1;
1994
+ continue;
1995
+ }
1996
+ const from = index;
1997
+ while (index < oldMid.length && oldMid[index] !== newMid[index]) index += 1;
1998
+ diffs.push(emit(from, index));
1999
+ }
2000
+ return diffs;
2001
+ }
2002
+ /**
2003
+ * Compute hunks and drop them when the serialized payload would exceed the cap.
2004
+ * @param path - remote path stamped on every hunk.
2005
+ * @param before - prior text.
2006
+ * @param after - text after the change.
2007
+ * @returns the kept hunks; empty when nothing changed or the payload is too big.
2008
+ */
2009
+ function boundedHunkDiffs(path, before, after) {
2010
+ const diffs = computeHunkDiffs(path, before, after);
2011
+ if (diffs.length === 0) return diffs;
2012
+ return JSON.stringify(diffs).length > 524288 ? [] : diffs;
2013
+ }
1666
2014
  //#endregion
1667
2015
  //#region src/engine.ts
2016
+ /** Longest single line the reader returns before cutting it (characters). */
2017
+ const READ_MAX_LINE_LENGTH = 2e3;
2018
+ /** Lines the reader returns when the caller does not choose a page size. */
2019
+ const READ_DEFAULT_LIMIT = 2e3;
2020
+ /** Most lines the reader will return in one call. */
2021
+ const READ_MAX_LIMIT = 5e3;
2022
+ /** Byte budget for one read window; keeps the envelope well under the transport limit. */
2023
+ const READ_MAX_BYTES = 512 * 1024;
1668
2024
  /** JSON-safe parse of the envelope output into a typed value. */
1669
2025
  function parseJson(text) {
1670
2026
  const trimmed = text.trim();
1671
2027
  if (trimmed === "" || trimmed === "null") return [];
1672
2028
  return JSON.parse(trimmed);
1673
2029
  }
2030
+ /**
2031
+ * Parse one JSON object out of an envelope's stdout.
2032
+ *
2033
+ * The file tools report their own failures inside the JSON (`{"error":…}`)
2034
+ * with exit code 0, so an unparseable body means the fragment never ran;
2035
+ * returning undefined lets the caller say so instead of throwing a
2036
+ * `SyntaxError` with no context.
2037
+ * @param text - envelope stdout.
2038
+ * @returns the parsed object, or undefined when the body is empty or not JSON.
2039
+ */
2040
+ function parseObject(text) {
2041
+ const trimmed = text.trim();
2042
+ if (trimmed === "" || trimmed === "null") return void 0;
2043
+ if (!trimmed.startsWith("{")) return void 0;
2044
+ try {
2045
+ return JSON.parse(trimmed);
2046
+ } catch {
2047
+ return;
2048
+ }
2049
+ }
2050
+ /** Mark an over-long line the reader cut, so the model knows it is incomplete. */
2051
+ function annotateCutLine(text) {
2052
+ return text.length > 2e3 ? `${text.slice(0, READ_MAX_LINE_LENGTH)}… (line truncated to ${READ_MAX_LINE_LENGTH} chars)` : text;
2053
+ }
2054
+ /** Encode text for a remote write, applying the requested byte-order mark. */
2055
+ function encodeText(content, encoding) {
2056
+ const body = Buffer.from(content, "utf8");
2057
+ if (encoding === "utf8bom") return Buffer.concat([Buffer.from([
2058
+ 239,
2059
+ 187,
2060
+ 191
2061
+ ]), body]);
2062
+ if (encoding === "utf16le") return Buffer.concat([Buffer.from([255, 254]), Buffer.from(content, "utf16le")]);
2063
+ return body;
2064
+ }
1674
2065
  /** A tiny per-alias in-flight limiter so GUI bursts do not pile up sockets. */
1675
2066
  var AliasGate = class {
1676
2067
  queues = /* @__PURE__ */ new Map();
@@ -1844,6 +2235,119 @@ var WinRmEngine = class {
1844
2235
  }));
1845
2236
  });
1846
2237
  }
2238
+ /**
2239
+ * Read a bounded, line-numbered window of a remote text file.
2240
+ *
2241
+ * One PowerShell round trip: the remote side decodes the file, counts every
2242
+ * line, and returns only the requested window, so a large log costs one
2243
+ * transfer of the window rather than of the file.
2244
+ * @param alias - configured host.
2245
+ * @param remotePath - path on the target.
2246
+ * @param offset - 1-based first line to return.
2247
+ * @param limit - maximum number of lines to return.
2248
+ * @returns the window plus the file's total line count and encoding.
2249
+ */
2250
+ async readFile(alias, remotePath, offset, limit) {
2251
+ const entry = this.store.find(alias);
2252
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
2253
+ return this.gate.run(alias, async () => {
2254
+ const result = await runScript(connOf(entry), psReadTextWindow(remotePath, offset, limit, READ_MAX_LINE_LENGTH, READ_MAX_BYTES), { timeoutMs: 12e4 });
2255
+ const payload = parseObject(result.stdout);
2256
+ if (payload === void 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to read '${remotePath}'`);
2257
+ if (payload.error !== void 0) throw new Error(`${remotePath}: ${payload.error} (the reader decodes UTF-8 and UTF-16 with a BOM; a binary file has no line structure to show)`);
2258
+ if (payload.exists !== true) throw new Error(`remote file not found: ${remotePath}`);
2259
+ const totalLines = payload.totalLines ?? 0;
2260
+ const lineCount = payload.lineCount ?? 0;
2261
+ if (payload.truncated !== true && offset > totalLines && totalLines > 0) throw new Error(`offset ${offset} is out of range for '${remotePath}' (${totalLines} lines)`);
2262
+ const texts = lineCount === 0 ? [] : (payload.linesText ?? "").split("\n");
2263
+ return {
2264
+ path: remotePath,
2265
+ offset,
2266
+ lines: texts.map((text, index) => ({
2267
+ number: offset + index,
2268
+ text: annotateCutLine(text)
2269
+ })),
2270
+ totalLines,
2271
+ encoding: payload.encoding ?? "utf8",
2272
+ truncatedLines: texts.filter((text) => text.length > READ_MAX_LINE_LENGTH).length,
2273
+ truncatedByBytes: payload.truncatedByBytes === true
2274
+ };
2275
+ });
2276
+ }
2277
+ /**
2278
+ * Write a whole remote text file, creating parent directories as needed.
2279
+ *
2280
+ * Reuses the transfer transport (single streamed stdin when available, the
2281
+ * 48 KiB chunk path otherwise) so a large write does not pay one shell cycle
2282
+ * per chunk.
2283
+ * @param alias - configured host.
2284
+ * @param remotePath - path on the target; overwritten when it exists.
2285
+ * @param content - UTF-8 text to write.
2286
+ * @param encoding - byte-order mark to emit (`utf8`, `utf8bom`, `utf16le`).
2287
+ * @returns the operation kind, byte count, encoding written, and both texts
2288
+ * the diff card compares.
2289
+ */
2290
+ async writeFile(alias, remotePath, content, encoding) {
2291
+ const entry = this.store.find(alias);
2292
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
2293
+ const data = encodeText(content, encoding);
2294
+ return this.gate.run(alias, async () => {
2295
+ const conn = connOf(entry);
2296
+ const previous = await remoteFileSize(conn, remotePath);
2297
+ let before = null;
2298
+ if (previous !== null && previous <= 262144) {
2299
+ const payload = parseObject((await runScript(conn, psReadTextBasis(remotePath, DIFF_BASIS_MAX_BYTES), { timeoutMs: 6e4 })).stdout);
2300
+ if (payload !== void 0 && payload.error === void 0 && payload.tooLarge !== true && typeof payload.text === "string") before = Buffer.from(payload.text, "base64").toString("utf8");
2301
+ }
2302
+ let bytes = await uploadStream(conn, remotePath, data);
2303
+ if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data);
2304
+ return {
2305
+ path: remotePath,
2306
+ operation: previous === null ? "create" : "update",
2307
+ bytes,
2308
+ encoding,
2309
+ before,
2310
+ after: content
2311
+ };
2312
+ });
2313
+ }
2314
+ /**
2315
+ * Replace `oldString` with `newString` inside a remote text file.
2316
+ *
2317
+ * The replacement is literal, never a pattern, and the file's existing
2318
+ * encoding is preserved. Without `replaceAll` the edit is refused unless the
2319
+ * needle is unique, so a stale needle cannot rewrite the wrong region.
2320
+ * @param alias - configured host.
2321
+ * @param remotePath - path on the target.
2322
+ * @param oldString - exact text to find.
2323
+ * @param newString - text to substitute.
2324
+ * @param replaceAll - replace every occurrence instead of requiring one.
2325
+ * @returns the replacement count, the file's new size, and both texts the
2326
+ * diff card compares.
2327
+ */
2328
+ async editFile(alias, remotePath, oldString, newString, replaceAll) {
2329
+ const entry = this.store.find(alias);
2330
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
2331
+ if (oldString === "") throw new Error("oldString must be a non-empty string");
2332
+ if (oldString === newString) throw new Error("oldString and newString are identical; nothing to change");
2333
+ const oldB64 = Buffer.from(oldString, "utf8").toString("base64");
2334
+ const newB64 = Buffer.from(newString, "utf8").toString("base64");
2335
+ return this.gate.run(alias, async () => {
2336
+ const result = await runScript(connOf(entry), psEditText(remotePath, oldB64, newB64, replaceAll, DIFF_BASIS_MAX_BYTES), { timeoutMs: 12e4 });
2337
+ const payload = parseObject(result.stdout);
2338
+ if (payload === void 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to edit '${remotePath}'`);
2339
+ if (payload.error !== void 0) throw new Error(payload.error);
2340
+ const basis = payload.basis ?? null;
2341
+ return {
2342
+ path: payload.path ?? remotePath,
2343
+ encoding: payload.encoding ?? "utf8",
2344
+ replacements: payload.replacements ?? 0,
2345
+ size: payload.size ?? 0,
2346
+ before: basis === null ? null : Buffer.from(basis.before ?? "", "base64").toString("utf8"),
2347
+ after: basis === null ? null : Buffer.from(basis.after ?? "", "base64").toString("utf8")
2348
+ };
2349
+ });
2350
+ }
1847
2351
  /** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
1848
2352
  async upload(alias, localPath, remotePath, onProgress, channel = "auto") {
1849
2353
  const entry = this.store.find(alias);
@@ -1969,16 +2473,19 @@ var WinRmEngine = class {
1969
2473
  percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
1970
2474
  });
1971
2475
  };
2476
+ let delivered = 0;
1972
2477
  try {
1973
2478
  const conn = connOf(entry);
1974
- if (await downloadStream(conn, remotePath, put, report) === null) {
2479
+ const streamed = await downloadStream(conn, remotePath, put, report);
2480
+ if (streamed === null) {
1975
2481
  let offset = 0;
1976
2482
  await downloadChunks(conn, remotePath, (b64) => {
1977
2483
  const buffer = Buffer.from(b64, "base64");
1978
2484
  put(offset, buffer);
1979
2485
  offset += buffer.length;
1980
2486
  }, report);
1981
- }
2487
+ delivered = bytes;
2488
+ } else delivered = streamed;
1982
2489
  await queue;
1983
2490
  if (sink === null) sink = await open(localPath, "w", 384);
1984
2491
  completed = true;
@@ -1990,12 +2497,12 @@ var WinRmEngine = class {
1990
2497
  onProgress?.({
1991
2498
  phase: "done",
1992
2499
  file: remotePath,
1993
- transferred: bytes,
1994
- total: bytes,
2500
+ transferred: delivered,
2501
+ total: delivered,
1995
2502
  percent: 100
1996
2503
  });
1997
2504
  return {
1998
- bytes,
2505
+ bytes: delivered,
1999
2506
  channel: "winrm"
2000
2507
  };
2001
2508
  });
@@ -3195,7 +3702,7 @@ function winrmUploadTool(engine) {
3195
3702
  "smb",
3196
3703
  "winrm"
3197
3704
  ],
3198
- 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."
3199
3706
  }
3200
3707
  },
3201
3708
  output: {
@@ -3239,7 +3746,7 @@ function winrmUploadTool(engine) {
3239
3746
  function winrmDownloadTool(engine) {
3240
3747
  return defineTool({
3241
3748
  name: "winrm_download",
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.",
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.",
3243
3750
  parameters: {
3244
3751
  alias: {
3245
3752
  type: "string",
@@ -3263,7 +3770,7 @@ function winrmDownloadTool(engine) {
3263
3770
  "smb",
3264
3771
  "winrm"
3265
3772
  ],
3266
- 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."
3267
3774
  }
3268
3775
  },
3269
3776
  output: {
@@ -3373,6 +3880,368 @@ function winrmClusterTool(engine) {
3373
3880
  }
3374
3881
  });
3375
3882
  }
3883
+ /** Render a read window in the numbered envelope the local read tool uses. */
3884
+ function renderWindow(window) {
3885
+ const lines = window.lines ?? [];
3886
+ const offset = window.offset ?? 1;
3887
+ const total = window.totalLines ?? 0;
3888
+ const last = offset + lines.length - 1;
3889
+ const body = lines.map((line) => `${line.number}: ${line.text}`).join("\n");
3890
+ const encoding = window.encoding !== void 0 && window.encoding !== "utf8" ? `, encoding ${window.encoding}` : "";
3891
+ let footer;
3892
+ if (lines.length === 0) footer = total === 0 ? `(End of file - total 0 lines${encoding})` : `(No lines at offset ${offset}; the file has ${total} lines${encoding}.)`;
3893
+ else if (window.truncatedByBytes === true) footer = `(Showing lines ${offset}-${last} of ${total}${encoding}. The window stopped at the byte budget — continue with offset=${last + 1}.)`;
3894
+ else if (last < total) footer = `(Showing lines ${offset}-${last} of ${total}${encoding}. Continue with offset=${last + 1}.)`;
3895
+ else footer = `(End of file - total ${total} lines${encoding})`;
3896
+ const content = body === "" ? footer : `${body}\n\n${footer}`;
3897
+ return [
3898
+ `<path>${window.path ?? ""}</path>`,
3899
+ "<type>file</type>",
3900
+ "<content>",
3901
+ content,
3902
+ "</content>"
3903
+ ].join("\n");
3904
+ }
3905
+ /** The remote read tool: a line-numbered window of a text file on the target. */
3906
+ function winrmReadTool(engine) {
3907
+ return defineTool({
3908
+ name: "winrm_read",
3909
+ description: "Read a text file ON A REMOTE Windows host and return its content line-numbered. The path is on the Windows host, not this machine — use `read` for local files. Decodes UTF-8 (with or without BOM) and UTF-16 with a BOM, and refuses binary files instead of returning mojibake. Page through large files with offset and limit. Triggers: read file on Windows server, inspect remote config/log/script.",
3910
+ parameters: {
3911
+ alias: {
3912
+ type: "string",
3913
+ required: true,
3914
+ description: "Host alias from winrm_list."
3915
+ },
3916
+ remotePath: {
3917
+ type: "string",
3918
+ required: true,
3919
+ description: "Path on the remote host, e.g. C:\\temp\\app.log."
3920
+ },
3921
+ offset: {
3922
+ type: "integer",
3923
+ description: "1-based first line to return. Defaults to 1."
3924
+ },
3925
+ limit: {
3926
+ type: "integer",
3927
+ description: `Maximum lines to return (default ${READ_DEFAULT_LIMIT}, max ${READ_MAX_LIMIT}).`
3928
+ }
3929
+ },
3930
+ output: {
3931
+ schema: {
3932
+ type: "object",
3933
+ additionalProperties: false,
3934
+ properties: {
3935
+ ok: {
3936
+ type: "boolean",
3937
+ required: true
3938
+ },
3939
+ path: { type: "string" },
3940
+ offset: { type: "integer" },
3941
+ lines: {
3942
+ type: "array",
3943
+ items: {
3944
+ type: "object",
3945
+ additionalProperties: false,
3946
+ properties: {
3947
+ number: {
3948
+ type: "integer",
3949
+ required: true
3950
+ },
3951
+ text: {
3952
+ type: "string",
3953
+ required: true
3954
+ }
3955
+ }
3956
+ }
3957
+ },
3958
+ totalLines: { type: "integer" },
3959
+ encoding: { type: "string" },
3960
+ truncatedByBytes: { type: "boolean" },
3961
+ truncatedLines: { type: "integer" },
3962
+ error: { type: "string" }
3963
+ }
3964
+ },
3965
+ render: (_args, value) => text(value.ok ? renderWindow(value) : `read failed: ${value.error ?? "unknown error"}`),
3966
+ presentationMeta: (args, value) => value.ok ? {
3967
+ card: "winrm-read",
3968
+ path: value.path ?? args.remotePath,
3969
+ offset: value.offset ?? 1,
3970
+ lines: value.lines ?? [],
3971
+ totalLines: value.totalLines ?? 0,
3972
+ encoding: value.encoding ?? "utf8",
3973
+ truncatedByBytes: value.truncatedByBytes === true,
3974
+ truncatedLines: value.truncatedLines ?? 0,
3975
+ error: ""
3976
+ } : {
3977
+ card: "winrm-read",
3978
+ path: args.remotePath,
3979
+ offset: 0,
3980
+ lines: [],
3981
+ totalLines: 0,
3982
+ encoding: "",
3983
+ truncatedByBytes: false,
3984
+ truncatedLines: 0,
3985
+ error: value.error ?? "unknown error"
3986
+ }
3987
+ },
3988
+ async execute(args) {
3989
+ const offset = Math.max(1, Math.trunc(args.offset ?? 1));
3990
+ const limit = Math.min(READ_MAX_LIMIT, Math.max(1, Math.trunc(args.limit ?? 2e3)));
3991
+ try {
3992
+ return {
3993
+ ok: true,
3994
+ ...await engine.readFile(args.alias, args.remotePath, offset, limit)
3995
+ };
3996
+ } catch (error) {
3997
+ return {
3998
+ ok: false,
3999
+ error: error instanceof Error ? error.message : String(error)
4000
+ };
4001
+ }
4002
+ }
4003
+ });
4004
+ }
4005
+ /** The remote write tool: replace a whole text file on the target. */
4006
+ function winrmWriteTool(engine) {
4007
+ return defineTool({
4008
+ name: "winrm_write",
4009
+ description: "Write a text file ON A REMOTE Windows host, creating parent directories and overwriting any existing file. The path is on the Windows host, not this machine — use `write` for local files, and prefer winrm_edit when only part of an existing file changes. Line endings are sent verbatim, so include \\r\\n for a Windows-native file. The encoding is whatever `encoding` asks for — rewriting an existing utf16le or utf8bom file without setting it converts the file to plain UTF-8; `winrm_read` names a non-UTF-8 file in its footer when you need to match one. Triggers: write file on Windows server, create remote script/config, overwrite remote file.",
4010
+ parameters: {
4011
+ alias: {
4012
+ type: "string",
4013
+ required: true,
4014
+ description: "Host alias from winrm_list."
4015
+ },
4016
+ remotePath: {
4017
+ type: "string",
4018
+ required: true,
4019
+ description: "Destination path on the remote host, e.g. C:\\temp\\script.ps1 (parent dirs are created)."
4020
+ },
4021
+ content: {
4022
+ type: "string",
4023
+ required: true,
4024
+ description: "Full file content."
4025
+ },
4026
+ encoding: {
4027
+ type: "string",
4028
+ enum: [
4029
+ "utf8",
4030
+ "utf8bom",
4031
+ "utf16le"
4032
+ ],
4033
+ description: "Byte-order mark to emit: utf8 (default, no BOM), utf8bom, or utf16le (PowerShell 5.1 reads a BOM-less script as ANSI, so non-ASCII script text needs a BOM). Does not preserve the existing file's encoding — pass it explicitly when overwriting a UTF-16 or BOM-carrying file."
4034
+ }
4035
+ },
4036
+ output: {
4037
+ schema: {
4038
+ type: "object",
4039
+ additionalProperties: false,
4040
+ properties: {
4041
+ ok: {
4042
+ type: "boolean",
4043
+ required: true
4044
+ },
4045
+ path: { type: "string" },
4046
+ operation: {
4047
+ type: "string",
4048
+ enum: ["create", "update"]
4049
+ },
4050
+ bytes: { type: "integer" },
4051
+ encoding: {
4052
+ type: "string",
4053
+ enum: [
4054
+ "utf8",
4055
+ "utf8bom",
4056
+ "utf16le"
4057
+ ]
4058
+ },
4059
+ diffs: {
4060
+ type: "array",
4061
+ items: {
4062
+ type: "object",
4063
+ additionalProperties: false,
4064
+ properties: {
4065
+ path: {
4066
+ type: "string",
4067
+ required: true
4068
+ },
4069
+ oldText: {
4070
+ type: "string",
4071
+ required: true
4072
+ },
4073
+ newText: {
4074
+ type: "string",
4075
+ required: true
4076
+ }
4077
+ }
4078
+ }
4079
+ },
4080
+ beyondBasis: { type: "boolean" },
4081
+ error: { type: "string" }
4082
+ }
4083
+ },
4084
+ render: (_args, value) => text(value.ok ? [
4085
+ `<path>${value.path ?? ""}</path>`,
4086
+ "<type>file</type>",
4087
+ "<content>",
4088
+ `${value.operation === "update" ? "Updated" : "Created"} file (${value.bytes ?? 0} bytes)`,
4089
+ "</content>"
4090
+ ].join("\n") : `write failed: ${value.error ?? "unknown error"}`),
4091
+ presentationMeta: (args, value) => value.ok ? {
4092
+ card: "winrm-write",
4093
+ path: value.path ?? args.remotePath,
4094
+ operation: value.operation ?? "update",
4095
+ bytes: value.bytes ?? 0,
4096
+ encoding: value.encoding ?? "utf8",
4097
+ diffs: value.diffs ?? [],
4098
+ beyondBasis: value.beyondBasis === true,
4099
+ error: ""
4100
+ } : {
4101
+ card: "winrm-write",
4102
+ path: args.remotePath,
4103
+ operation: "update",
4104
+ bytes: 0,
4105
+ encoding: "",
4106
+ diffs: [],
4107
+ beyondBasis: false,
4108
+ error: value.error ?? "unknown error"
4109
+ }
4110
+ },
4111
+ async execute(args) {
4112
+ try {
4113
+ const outcome = await engine.writeFile(args.alias, args.remotePath, args.content, args.encoding ?? "utf8");
4114
+ const diffs = outcome.before !== null ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after) : outcome.operation === "create" ? boundedHunkDiffs(outcome.path, "", outcome.after) : [];
4115
+ return {
4116
+ ok: true,
4117
+ path: outcome.path,
4118
+ operation: outcome.operation,
4119
+ bytes: outcome.bytes,
4120
+ encoding: outcome.encoding,
4121
+ diffs,
4122
+ beyondBasis: outcome.operation === "update" && outcome.before === null
4123
+ };
4124
+ } catch (error) {
4125
+ return {
4126
+ ok: false,
4127
+ error: error instanceof Error ? error.message : String(error)
4128
+ };
4129
+ }
4130
+ }
4131
+ });
4132
+ }
4133
+ /** The remote edit tool: replace an exact string inside a file on the target. */
4134
+ function winrmEditTool(engine) {
4135
+ return defineTool({
4136
+ name: "winrm_edit",
4137
+ description: "Replace an exact string in a text file ON A REMOTE Windows host, preserving the file encoding. The path is on the Windows host, not this machine — use `edit` for local files. `oldString` must match the file byte for byte, including indentation and line endings, and must be unique unless `replaceAll` is set; read the file with winrm_read first. Triggers: edit file on Windows server, patch remote config, change remote script.",
4138
+ parameters: {
4139
+ alias: {
4140
+ type: "string",
4141
+ required: true,
4142
+ description: "Host alias from winrm_list."
4143
+ },
4144
+ remotePath: {
4145
+ type: "string",
4146
+ required: true,
4147
+ description: "Path on the remote host, e.g. C:\\temp\\app.config."
4148
+ },
4149
+ oldString: {
4150
+ type: "string",
4151
+ required: true,
4152
+ description: "Exact text to replace. Must not be empty."
4153
+ },
4154
+ newString: {
4155
+ type: "string",
4156
+ required: true,
4157
+ description: "Replacement text. Must differ from oldString."
4158
+ },
4159
+ replaceAll: {
4160
+ type: "boolean",
4161
+ description: "Replace every occurrence instead of requiring exactly one. Defaults to false."
4162
+ }
4163
+ },
4164
+ output: {
4165
+ schema: {
4166
+ type: "object",
4167
+ additionalProperties: false,
4168
+ properties: {
4169
+ ok: {
4170
+ type: "boolean",
4171
+ required: true
4172
+ },
4173
+ path: { type: "string" },
4174
+ encoding: { type: "string" },
4175
+ replacements: { type: "integer" },
4176
+ size: { type: "integer" },
4177
+ diffs: {
4178
+ type: "array",
4179
+ items: {
4180
+ type: "object",
4181
+ additionalProperties: false,
4182
+ properties: {
4183
+ path: {
4184
+ type: "string",
4185
+ required: true
4186
+ },
4187
+ oldText: {
4188
+ type: "string",
4189
+ required: true
4190
+ },
4191
+ newText: {
4192
+ type: "string",
4193
+ required: true
4194
+ }
4195
+ }
4196
+ }
4197
+ },
4198
+ beyondBasis: { type: "boolean" },
4199
+ error: { type: "string" }
4200
+ }
4201
+ },
4202
+ render: (_args, value) => text(value.ok ? (value.replacements ?? 0) > 1 ? `The file ${value.path ?? ""} has been updated. All ${value.replacements ?? 0} occurrences were successfully replaced.` : `The file ${value.path ?? ""} has been updated successfully.` : `edit failed: ${value.error ?? "unknown error"}`),
4203
+ presentationMeta: (args, value) => value.ok ? {
4204
+ card: "winrm-edit",
4205
+ path: value.path ?? args.remotePath,
4206
+ replacements: value.replacements ?? 0,
4207
+ size: value.size ?? 0,
4208
+ encoding: value.encoding ?? "utf8",
4209
+ diffs: value.diffs ?? [],
4210
+ beyondBasis: value.beyondBasis === true,
4211
+ error: ""
4212
+ } : {
4213
+ card: "winrm-edit",
4214
+ path: args.remotePath,
4215
+ replacements: 0,
4216
+ size: 0,
4217
+ encoding: "",
4218
+ diffs: [],
4219
+ beyondBasis: false,
4220
+ error: value.error ?? "unknown error"
4221
+ }
4222
+ },
4223
+ async execute(args) {
4224
+ try {
4225
+ const outcome = await engine.editFile(args.alias, args.remotePath, args.oldString, args.newString, args.replaceAll ?? false);
4226
+ const diffs = outcome.before !== null && outcome.after !== null ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after) : [];
4227
+ return {
4228
+ ok: true,
4229
+ path: outcome.path,
4230
+ encoding: outcome.encoding,
4231
+ replacements: outcome.replacements,
4232
+ size: outcome.size,
4233
+ diffs,
4234
+ beyondBasis: outcome.before === null
4235
+ };
4236
+ } catch (error) {
4237
+ return {
4238
+ ok: false,
4239
+ error: error instanceof Error ? error.message : String(error)
4240
+ };
4241
+ }
4242
+ }
4243
+ });
4244
+ }
3376
4245
  //#endregion
3377
4246
  //#region src/mount-once.ts
3378
4247
  /**
@@ -3435,7 +4304,7 @@ const DEFAULT_ENABLED = true;
3435
4304
  /** Order of the announcement section within the tool-guidance band. */
3436
4305
  const SECTION_ORDER = 151;
3437
4306
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
3439
4308
  /**
3440
4309
  * Mount the WinRM engine, routes, tools, and announcement.
3441
4310
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -3458,6 +4327,9 @@ function applyImpl(ctx, config) {
3458
4327
  winrmExecTool(engine),
3459
4328
  winrmServiceTool(engine),
3460
4329
  winrmProcessTool(engine),
4330
+ winrmReadTool(engine),
4331
+ winrmWriteTool(engine),
4332
+ winrmEditTool(engine),
3461
4333
  winrmUploadTool(engine),
3462
4334
  winrmDownloadTool(engine),
3463
4335
  winrmClusterTool(engine)