dsh-winrm 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -9,6 +9,50 @@ import { homedir, tmpdir } from "node:os";
9
9
  import { WebSocket, WebSocketServer } from "ws";
10
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
11
  //#region src/powershell.ts
12
+ /**
13
+ * PowerShell snippet builders and the WinRM UTF-8 envelope.
14
+ *
15
+ * The user script is delivered on the WinRS **stdin** stream, not on the
16
+ * command line. WinRS hands the command line to cmd.exe, which caps it at
17
+ * ~8191 characters; `-EncodedCommand` inflates a script by 2.67x
18
+ * (UTF-16LE → base64), so the command line can carry only about 1.5 KB of
19
+ * script. That silently broke every large payload — most visibly the 48 KiB
20
+ * transfer chunk, whose embedded base64 alone is ~65K characters.
21
+ *
22
+ * So the command line is now a *constant* outer script and the user script
23
+ * rides stdin, which has no such limit (verified to 200 KB).
24
+ *
25
+ * The outer script reads a byte count, then that many bytes, starts a child
26
+ * `powershell.exe` and feeds it the script on its stdin. The process boundary
27
+ * is what makes `exit N` and an uncaught `throw` survivable: they terminate
28
+ * the *child*, and the outer script is still alive to print the envelope.
29
+ * `-EncodedCommand` for the child also sidesteps the execution policy
30
+ * entirely — the policy governs `.ps1` files, not encoded commands — so no
31
+ * temp file is written and no policy block can lose the envelope.
32
+ *
33
+ * The child body is `& (...) 2>&1 | Out-String -Stream`. Both halves matter:
34
+ * `2>&1` pulls error records into the success stream, `Out-String -Stream`
35
+ * stringifies them onto stdout *line by line*. Plain `Out-String` would also
36
+ * stringify them but buffers the whole pipeline, discarding everything
37
+ * produced before a mid-script `exit`; bare `2>&1` without `Out-String`
38
+ * leaves the records to be re-serialized as CLIXML on stderr, where the
39
+ * envelope's CLIXML stripping deletes the text outright.
40
+ *
41
+ * The envelope is three lines — a marker with the exit code, then one base64
42
+ * line per stream:
43
+ *
44
+ * __DSH_WINRM__<code>
45
+ * O:<base64 of UTF-8 stdout>
46
+ * E:<base64 of UTF-8 stderr>
47
+ *
48
+ * Every payload is ASCII, so the transport cannot corrupt it and Chinese
49
+ * output survives losslessly. The `O:` / `E:` labels are load-bearing: the
50
+ * WinRM client trims each response chunk, which can eat the newline between
51
+ * two lines, and base64 never contains `:`, so the labels stay parseable
52
+ * even when the separators vanish.
53
+ */
54
+ /** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
55
+ const ENVELOPE_MARKER = "__DSH_WINRM__";
12
56
  /** PS single-quoted literal (doubles embedded quotes). */
13
57
  function sq(value) {
14
58
  return "'" + value.replace(/'/g, "''") + "'";
@@ -18,28 +62,121 @@ function encodeCommand(script) {
18
62
  return Buffer.from(script, "utf16le").toString("base64");
19
63
  }
20
64
  /**
21
- * The UTF-8 WinRM session script: runs `userScript` inside a script block,
22
- * merges stderr, prints the exit-code marker and base64 of UTF-8 output.
65
+ * Outer script (constant, so the command line never grows with the payload):
66
+ * read `<byteCount>\n<utf8 script>` from stdin, run it in a child
67
+ * powershell.exe, then print the three-line envelope.
68
+ *
69
+ * The child is started with redirected streams *before* anything is written to
70
+ * its stdin, and both readers are started before the write, so a child that
71
+ * fills a pipe buffer cannot deadlock the outer script. Closing the writer is
72
+ * what gives the child a real EOF — without it the child would block forever
73
+ * on `CopyTo`.
23
74
  */
24
- function wrapEnvelope(userScript) {
25
- return [
26
- "$ErrorActionPreference = 'Continue'",
27
- "$__out = & { " + userScript + " } 2>&1 | Out-String -Width 4096",
28
- "$__code = 0",
29
- "if ($null -ne $LASTEXITCODE) { $__code = $LASTEXITCODE }",
30
- "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
31
- "'__DSH_WINRM__' + [string]$__code",
32
- "[System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__out))",
75
+ const OUTER_SCRIPT = [
76
+ "$ErrorActionPreference='Continue'",
77
+ "$z=@'",
78
+ [
79
+ "$ErrorActionPreference='Continue'",
80
+ "[Console]::OutputEncoding=[Text.Encoding]::UTF8",
81
+ "$i=[Console]::OpenStandardInput()",
82
+ "$m=New-Object System.IO.MemoryStream",
83
+ "$i.CopyTo($m)",
84
+ "$s=[Text.Encoding]::UTF8.GetString($m.ToArray())",
85
+ "if($s.Length -gt 0 -and [int]$s[0] -eq 0xFEFF){$s=$s.Substring(1)}",
86
+ "$c=0",
87
+ "try{ &([scriptblock]::Create($s)) 2>&1 | Out-String -Stream -Width 4096",
88
+ "if($null -ne $LASTEXITCODE){$c=$LASTEXITCODE}",
89
+ "}catch{ $_|Out-String; $c=1 }",
90
+ "exit $c",
33
91
  ""
34
- ].join("\r\n");
92
+ ].join("\r\n"),
93
+ "'@",
94
+ "$a=''",
95
+ "$e=''",
96
+ "$c=0",
97
+ "try{",
98
+ " $i=[Console]::OpenStandardInput()",
99
+ " $d=''",
100
+ " while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}",
101
+ " $n=[int]$d",
102
+ " $u=New-Object byte[] $n",
103
+ " $r=0",
104
+ " while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}",
105
+ " $s=[Text.Encoding]::UTF8.GetString($u,0,$r)",
106
+ " $p=New-Object System.Diagnostics.ProcessStartInfo",
107
+ " $p.FileName='powershell.exe'",
108
+ " $p.UseShellExecute=$false",
109
+ " $p.RedirectStandardOutput=$true",
110
+ " $p.RedirectStandardError=$true",
111
+ " $p.RedirectStandardInput=$true",
112
+ " $p.StandardOutputEncoding=[Text.Encoding]::UTF8",
113
+ " $p.StandardErrorEncoding=[Text.Encoding]::UTF8",
114
+ " $p.CreateNoWindow=$true",
115
+ " $p.Arguments='-NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '+[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($z))",
116
+ " $q=[System.Diagnostics.Process]::Start($p)",
117
+ " $x=$q.StandardOutput.ReadToEndAsync()",
118
+ " $y=$q.StandardError.ReadToEndAsync()",
119
+ " $w=New-Object System.IO.StreamWriter($q.StandardInput.BaseStream,(New-Object System.Text.UTF8Encoding($false)))",
120
+ " $w.Write($s)",
121
+ " $w.Flush()",
122
+ " $w.Close()",
123
+ " $q.WaitForExit()",
124
+ " $a=[string]$x.Result",
125
+ " $e=[string]$y.Result",
126
+ " $c=$q.ExitCode",
127
+ "}catch{",
128
+ " $a=[string]$a+[string]($_|Out-String)",
129
+ " $c=1",
130
+ "}",
131
+ sq(ENVELOPE_MARKER) + "+[string]$c",
132
+ "'O:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$a))",
133
+ "'E:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$e))",
134
+ ""
135
+ ].join("\r\n");
136
+ /**
137
+ * The constant powershell.exe command line that runs the envelope.
138
+ *
139
+ * Deliberately takes no payload: keeping the command line under cmd.exe's
140
+ * ~8191-character cap is the whole point of the stdin channel.
141
+ */
142
+ function powershellCommandLine() {
143
+ return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(OUTER_SCRIPT);
35
144
  }
36
- /** One-shot script body for the PowerShell session already created by WinRM. */
37
- function winrmPowerShellScript(userScript) {
38
- return wrapEnvelope(userScript);
145
+ /**
146
+ * The stdin payload for one command: the UTF-8 **byte** count, a newline, then
147
+ * the raw script.
148
+ *
149
+ * The count is in bytes, not characters, because the outer script sizes its
150
+ * receive buffer from it — a script with multi-byte characters would otherwise
151
+ * be truncated mid-character.
152
+ * @param userScript - the PowerShell script to run.
153
+ * @returns the payload to send on WinRS stdin.
154
+ */
155
+ function scriptPayload(userScript) {
156
+ return String(Buffer.byteLength(userScript, "utf8")) + "\n" + userScript;
39
157
  }
40
- /** The full powershell.exe command line (ASCII-safe) for a script body. */
41
- function powershellCommandLine(script) {
42
- return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(script);
158
+ /**
159
+ * Split a payload into Send-sized pieces without cutting a surrogate pair.
160
+ *
161
+ * `Command.doSendInput` encodes each piece to UTF-8 independently, so a lone
162
+ * surrogate at a chunk boundary would be replaced with U+FFFD and corrupt the
163
+ * script. Iterating with `for…of` yields whole code points, so a pair always
164
+ * stays in one piece.
165
+ * @param payload - the full stdin payload.
166
+ * @returns the pieces to send in order; never empty.
167
+ */
168
+ function splitScriptPayload(payload) {
169
+ const pieces = [];
170
+ let piece = "";
171
+ for (const ch of payload) {
172
+ if (piece.length + ch.length > 16384) {
173
+ pieces.push(piece);
174
+ piece = "";
175
+ }
176
+ piece += ch;
177
+ }
178
+ if (piece !== "" || pieces.length === 0) pieces.push(piece);
179
+ return pieces;
43
180
  }
44
181
  /** JSON: full service table (CIM). */
45
182
  function psListServices() {
@@ -128,38 +265,39 @@ function psWriteChunk(p, b64, append) {
128
265
  ""
129
266
  ].join("\r\n");
130
267
  }
268
+ /** Decode one base64 envelope field, tolerating whitespace the transport added. */
269
+ function decodeField(base64) {
270
+ return Buffer.from(base64.replace(/\s+/g, ""), "base64").toString("utf8");
271
+ }
131
272
  /**
132
- * Parse the envelope output: { exitCode, text } or null when absent.
273
+ * Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
133
274
  *
134
- * The base64 body is optional and the separating newline may be missing: a
135
- * command that printed nothing emits the marker alone (an empty string has
136
- * no base64 body), and a transport that trims each response drops the
137
- * trailing newline. The exit code is signed because `$LASTEXITCODE` can be
138
- * negative.
275
+ * Fields are located by their `O:` / `E:` labels rather than by splitting on
276
+ * newlines, because the WinRM client trims every response chunk and can drop
277
+ * the newline that separated two lines. base64's alphabet has no colon, so a
278
+ * label can only ever be a delimiter and the search cannot land on payload
279
+ * data. The exit code is signed because `$LASTEXITCODE` can be negative.
280
+ * @param output - the raw stdout accumulated from the WinRS stdout stream.
281
+ * @returns the decoded envelope, or null when the marker is missing.
139
282
  */
140
283
  function parseEnvelope(output) {
141
- const match = /^__DSH_WINRM__(-?\d+)(?:\r?\n([\s\S]*))?$/.exec(output);
142
- if (match === null) return null;
143
- const exitCode = Number.parseInt(match[1], 10);
144
- const payload = (match[2] ?? "").trim();
145
- try {
146
- return {
147
- exitCode,
148
- text: Buffer.from(payload, "base64").toString("utf8")
149
- };
150
- } catch {
151
- return {
152
- exitCode,
153
- text: payload
154
- };
155
- }
284
+ const text = output.trim();
285
+ const head = (/* @__PURE__ */ new RegExp("^__DSH_WINRM__(-?\\d+)")).exec(text);
286
+ if (head === null) return null;
287
+ const stdoutAt = text.indexOf("O:");
288
+ const stderrAt = stdoutAt === -1 ? -1 : text.indexOf("E:", stdoutAt + 2);
289
+ return {
290
+ exitCode: Number.parseInt(head[1], 10),
291
+ stdout: stdoutAt === -1 ? "" : decodeField(text.slice(stdoutAt + 2, stderrAt === -1 ? void 0 : stderrAt)),
292
+ stderr: stderrAt === -1 ? "" : decodeField(text.slice(stderrAt + 2))
293
+ };
156
294
  }
157
295
  /** The `#< CLIXML` stream header PowerShell writes once before its serialized records. */
158
296
  const CLIXML_HEADER = /^#< CLIXML\r?$/gm;
159
297
  /** One serialized record block (`<Objs …>…</Objs>`) holding host progress/verbose records. */
160
298
  const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g;
161
299
  /**
162
- * Remove PowerShell's CLIXML host records from the WinRS stderr stream.
300
+ * Remove PowerShell's CLIXML host records from a stderr stream.
163
301
  *
164
302
  * PowerShell marks its stderr stream with a `#< CLIXML` header line and
165
303
  * serializes the remote host's own progress/verbose records into `<Objs>`
@@ -168,9 +306,10 @@ const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g;
168
306
  * header and a record block (for example `[Console]::Error`) is real output,
169
307
  * not part of the CLIXML payload.
170
308
  *
171
- * The script's own error output is already merged into stdout by the
172
- * envelope, so this only removes transport-level noise.
173
- * @param stderr - raw stderr text captured from the WinRS stream.
309
+ * The script's own PowerShell errors are already stringified into the
310
+ * envelope's stdout by `Out-String -Stream`, so this only removes
311
+ * transport-level noise such as module-load progress.
312
+ * @param stderr - raw stderr text captured from the WinRS stream or the envelope.
174
313
  * @returns the stderr text without CLIXML host records.
175
314
  */
176
315
  function stripClixml(stderr) {
@@ -182,11 +321,11 @@ function stripClixml(stderr) {
182
321
  * WinRM transport bridge.
183
322
  *
184
323
  * Runs each PowerShell payload over a native Node.js WinRM client
185
- * (winrm-client) — no Python/pywinrm subprocess. The script rides the
186
- * UTF-8 base64 envelope (marker + base64 of UTF-8 output) so Chinese and
187
- * any codepage output survives the WinRM transport losslessly; the envelope
188
- * is base64-encoded with `-EncodedCommand`, keeping the command line a
189
- * single ASCII-safe line that WinRS/cmd.exe cannot mangle.
324
+ * (winrm-client) — no Python/pywinrm subprocess. The script is delivered on
325
+ * the WinRS **stdin** stream and answers on the UTF-8 base64 envelope, so
326
+ * Chinese and any codepage output survives the transport losslessly and the
327
+ * command line stays a constant, ASCII-safe line that WinRS/cmd.exe cannot
328
+ * mangle. See powershell.ts for why the command line cannot carry the script.
190
329
  *
191
330
  * Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
192
331
  * first, then Basic) with fallback, so a host that only enables one scheme
@@ -245,17 +384,17 @@ function baseParams(conn, authMethod) {
245
384
  };
246
385
  }
247
386
  /**
248
- * One shell lifecycle: create → run the encoded script → receive until the
249
- * command completes → delete. Bounded by an overall deadline; on timeout the
250
- * delete is fired without blocking the caller.
387
+ * One shell lifecycle: create → run the constant envelope script → push the
388
+ * payload on stdin → receive until the command completes → delete. Bounded by
389
+ * an overall deadline; on timeout the delete is fired without blocking.
251
390
  * @param conn - connection parameters.
252
- * @param envelope - the wrapped UTF-8 envelope script (already prepared).
391
+ * @param payload - the stdin payload (`<byteCount>\n<script>`).
253
392
  * @param timeoutMs - command execution budget measured from just after Execute.
254
393
  * @param authMethod - the auth scheme for this attempt.
255
394
  * @returns stdout/stderr captured from the WinRS streams.
256
395
  * @throws WinrmTimedOut when the deadline passes; other errors are auth/transport failures.
257
396
  */
258
- async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
397
+ async function attemptOnce(conn, payload, timeoutMs, authMethod) {
259
398
  const base = baseParams(conn, authMethod);
260
399
  const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
261
400
  const state = {
@@ -273,11 +412,16 @@ async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
273
412
  }).catch(() => void 0);
274
413
  throw new WinrmTimedOut();
275
414
  }
276
- const command = powershellCommandLine(envelope);
277
415
  const commandId = await Command.doExecuteCommand({
278
416
  ...base,
279
417
  shellId,
280
- command
418
+ command: powershellCommandLine()
419
+ });
420
+ for (const piece of splitScriptPayload(payload)) await Command.doSendInput({
421
+ ...base,
422
+ shellId,
423
+ commandId,
424
+ input: piece
281
425
  });
282
426
  const receive = {
283
427
  ...base,
@@ -324,16 +468,16 @@ async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
324
468
  * succeeds. A timeout aborts immediately (retrying auth would waste the
325
469
  * remaining budget on a reachable-but-slow host).
326
470
  * @param conn - connection parameters.
327
- * @param envelope - the wrapped UTF-8 envelope script.
471
+ * @param payload - the stdin payload (`<byteCount>\n<script>`).
328
472
  * @param timeoutMs - command execution budget.
329
473
  * @returns captured stdout/stderr.
330
474
  * @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
331
475
  */
332
- async function runWinrm(conn, envelope, timeoutMs) {
476
+ async function runWinrm(conn, payload, timeoutMs) {
333
477
  const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
334
478
  let lastError;
335
479
  for (const authMethod of candidates) try {
336
- return await attemptOnce(conn, envelope, timeoutMs, authMethod);
480
+ return await attemptOnce(conn, payload, timeoutMs, authMethod);
337
481
  } catch (error) {
338
482
  if (error instanceof WinrmTimedOut) throw error;
339
483
  lastError = error;
@@ -343,10 +487,10 @@ async function runWinrm(conn, envelope, timeoutMs) {
343
487
  async function runScript(conn, script, options = {}) {
344
488
  const started = Date.now();
345
489
  const timeoutMs = options.timeoutMs ?? 6e4;
346
- const envelope = winrmPowerShellScript(script);
490
+ const payload = scriptPayload(script);
347
491
  let outcome;
348
492
  try {
349
- outcome = await runWinrm(conn, envelope, timeoutMs);
493
+ outcome = await runWinrm(conn, payload, timeoutMs);
350
494
  } catch (error) {
351
495
  if (error instanceof WinrmTimedOut) return {
352
496
  stdout: "",
@@ -360,13 +504,13 @@ async function runScript(conn, script, options = {}) {
360
504
  const raw = outcome.stdout;
361
505
  const parsed = parseEnvelope(raw);
362
506
  const exitCode = parsed !== null ? parsed.exitCode : 1;
363
- const stderr = parsed !== null ? stripClixml(outcome.stderr) : outcome.stderr;
507
+ const stderr = parsed !== null ? stripClixml(parsed.stderr) : outcome.stderr;
364
508
  options.onChunk?.({
365
509
  output: raw,
366
510
  stderr
367
511
  });
368
512
  return {
369
- stdout: parsed !== null ? parsed.text : raw,
513
+ stdout: parsed !== null ? parsed.stdout : raw,
370
514
  stderr,
371
515
  exitCode,
372
516
  timedOut: false,
@@ -1839,6 +1983,7 @@ function winrmListTool(engine) {
1839
1983
  enum: ["http", "https"],
1840
1984
  required: true
1841
1985
  },
1986
+ rejectUnauthorized: { type: "boolean" },
1842
1987
  description: { type: "string" },
1843
1988
  environment: { type: "string" },
1844
1989
  tags: {
@@ -2,11 +2,11 @@
2
2
  * WinRM transport bridge.
3
3
  *
4
4
  * Runs each PowerShell payload over a native Node.js WinRM client
5
- * (winrm-client) — no Python/pywinrm subprocess. The script rides the
6
- * UTF-8 base64 envelope (marker + base64 of UTF-8 output) so Chinese and
7
- * any codepage output survives the WinRM transport losslessly; the envelope
8
- * is base64-encoded with `-EncodedCommand`, keeping the command line a
9
- * single ASCII-safe line that WinRS/cmd.exe cannot mangle.
5
+ * (winrm-client) — no Python/pywinrm subprocess. The script is delivered on
6
+ * the WinRS **stdin** stream and answers on the UTF-8 base64 envelope, so
7
+ * Chinese and any codepage output survives the transport losslessly and the
8
+ * command line stays a constant, ASCII-safe line that WinRS/cmd.exe cannot
9
+ * mangle. See powershell.ts for why the command line cannot carry the script.
10
10
  *
11
11
  * Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
12
12
  * first, then Basic) with fallback, so a host that only enables one scheme
@@ -1,28 +1,82 @@
1
1
  /**
2
- * PowerShell snippet builders. Every payload rides the UTF-8 base64
3
- * envelope: the WinRM host prints only ASCII (marker + base64 of UTF-8 text)
4
- * so Chinese / any codepage output survives the transport losslessly. One-shot
5
- * commands execute directly in the PowerShell session created by WinRM.
2
+ * PowerShell snippet builders and the WinRM UTF-8 envelope.
3
+ *
4
+ * The user script is delivered on the WinRS **stdin** stream, not on the
5
+ * command line. WinRS hands the command line to cmd.exe, which caps it at
6
+ * ~8191 characters; `-EncodedCommand` inflates a script by 2.67x
7
+ * (UTF-16LE → base64), so the command line can carry only about 1.5 KB of
8
+ * script. That silently broke every large payload — most visibly the 48 KiB
9
+ * transfer chunk, whose embedded base64 alone is ~65K characters.
10
+ *
11
+ * So the command line is now a *constant* outer script and the user script
12
+ * rides stdin, which has no such limit (verified to 200 KB).
13
+ *
14
+ * The outer script reads a byte count, then that many bytes, starts a child
15
+ * `powershell.exe` and feeds it the script on its stdin. The process boundary
16
+ * is what makes `exit N` and an uncaught `throw` survivable: they terminate
17
+ * the *child*, and the outer script is still alive to print the envelope.
18
+ * `-EncodedCommand` for the child also sidesteps the execution policy
19
+ * entirely — the policy governs `.ps1` files, not encoded commands — so no
20
+ * temp file is written and no policy block can lose the envelope.
21
+ *
22
+ * The child body is `& (...) 2>&1 | Out-String -Stream`. Both halves matter:
23
+ * `2>&1` pulls error records into the success stream, `Out-String -Stream`
24
+ * stringifies them onto stdout *line by line*. Plain `Out-String` would also
25
+ * stringify them but buffers the whole pipeline, discarding everything
26
+ * produced before a mid-script `exit`; bare `2>&1` without `Out-String`
27
+ * leaves the records to be re-serialized as CLIXML on stderr, where the
28
+ * envelope's CLIXML stripping deletes the text outright.
29
+ *
30
+ * The envelope is three lines — a marker with the exit code, then one base64
31
+ * line per stream:
32
+ *
33
+ * __DSH_WINRM__<code>
34
+ * O:<base64 of UTF-8 stdout>
35
+ * E:<base64 of UTF-8 stderr>
36
+ *
37
+ * Every payload is ASCII, so the transport cannot corrupt it and Chinese
38
+ * output survives losslessly. The `O:` / `E:` labels are load-bearing: the
39
+ * WinRM client trims each response chunk, which can eat the newline between
40
+ * two lines, and base64 never contains `:`, so the labels stay parseable
41
+ * even when the separators vanish.
6
42
  */
7
43
  /** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
8
44
  export declare const ENVELOPE_MARKER = "__DSH_WINRM__";
45
+ /** Max characters per stdin Send; split on code points, never mid-surrogate-pair. */
46
+ export declare const STDIN_CHUNK_CHARS = 16384;
9
47
  /** PS single-quoted literal (doubles embedded quotes). */
10
48
  export declare function sq(value: string): string;
11
49
  /** Base64 of a UTF-16LE PowerShell script, for -EncodedCommand. */
12
50
  export declare function encodeCommand(script: string): string;
13
51
  /**
14
- * The UTF-8 WinRM session script: runs `userScript` inside a script block,
15
- * merges stderr, prints the exit-code marker and base64 of UTF-8 output.
52
+ * The constant powershell.exe command line that runs the envelope.
53
+ *
54
+ * Deliberately takes no payload: keeping the command line under cmd.exe's
55
+ * ~8191-character cap is the whole point of the stdin channel.
56
+ */
57
+ export declare function powershellCommandLine(): string;
58
+ /**
59
+ * The stdin payload for one command: the UTF-8 **byte** count, a newline, then
60
+ * the raw script.
61
+ *
62
+ * The count is in bytes, not characters, because the outer script sizes its
63
+ * receive buffer from it — a script with multi-byte characters would otherwise
64
+ * be truncated mid-character.
65
+ * @param userScript - the PowerShell script to run.
66
+ * @returns the payload to send on WinRS stdin.
67
+ */
68
+ export declare function scriptPayload(userScript: string): string;
69
+ /**
70
+ * Split a payload into Send-sized pieces without cutting a surrogate pair.
71
+ *
72
+ * `Command.doSendInput` encodes each piece to UTF-8 independently, so a lone
73
+ * surrogate at a chunk boundary would be replaced with U+FFFD and corrupt the
74
+ * script. Iterating with `for…of` yields whole code points, so a pair always
75
+ * stays in one piece.
76
+ * @param payload - the full stdin payload.
77
+ * @returns the pieces to send in order; never empty.
16
78
  */
17
- export declare function wrapEnvelope(userScript: string): string;
18
- /** One-shot script body for the PowerShell session already created by WinRM. */
19
- export declare function winrmPowerShellScript(userScript: string): string;
20
- /** The full powershell.exe command line (ASCII-safe) for a script body. */
21
- export declare function powershellCommandLine(script: string): string;
22
- /** One-shot non-interactive PowerShell command line (envelope-wrapped). */
23
- export declare function execCommandLine(userScript: string): string;
24
- /** Interactive bootstrap for the streaming console (keeps the shell alive). */
25
- export declare function consoleCommandLine(): string;
79
+ export declare function splitScriptPayload(payload: string): string[];
26
80
  /** JSON: full service table (CIM). */
27
81
  export declare function psListServices(): string;
28
82
  /** JSON: one service after an action. */
@@ -43,20 +97,23 @@ export declare function psReadChunk(p: string, offset: number, count: number): s
43
97
  */
44
98
  export declare function psWriteChunk(p: string, b64: string, append: boolean): string;
45
99
  /**
46
- * Parse the envelope output: { exitCode, text } or null when absent.
100
+ * Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
47
101
  *
48
- * The base64 body is optional and the separating newline may be missing: a
49
- * command that printed nothing emits the marker alone (an empty string has
50
- * no base64 body), and a transport that trims each response drops the
51
- * trailing newline. The exit code is signed because `$LASTEXITCODE` can be
52
- * negative.
102
+ * Fields are located by their `O:` / `E:` labels rather than by splitting on
103
+ * newlines, because the WinRM client trims every response chunk and can drop
104
+ * the newline that separated two lines. base64's alphabet has no colon, so a
105
+ * label can only ever be a delimiter and the search cannot land on payload
106
+ * data. The exit code is signed because `$LASTEXITCODE` can be negative.
107
+ * @param output - the raw stdout accumulated from the WinRS stdout stream.
108
+ * @returns the decoded envelope, or null when the marker is missing.
53
109
  */
54
110
  export declare function parseEnvelope(output: string): {
55
111
  exitCode: number;
56
- text: string;
112
+ stdout: string;
113
+ stderr: string;
57
114
  } | null;
58
115
  /**
59
- * Remove PowerShell's CLIXML host records from the WinRS stderr stream.
116
+ * Remove PowerShell's CLIXML host records from a stderr stream.
60
117
  *
61
118
  * PowerShell marks its stderr stream with a `#< CLIXML` header line and
62
119
  * serializes the remote host's own progress/verbose records into `<Objs>`
@@ -65,9 +122,10 @@ export declare function parseEnvelope(output: string): {
65
122
  * header and a record block (for example `[Console]::Error`) is real output,
66
123
  * not part of the CLIXML payload.
67
124
  *
68
- * The script's own error output is already merged into stdout by the
69
- * envelope, so this only removes transport-level noise.
70
- * @param stderr - raw stderr text captured from the WinRS stream.
125
+ * The script's own PowerShell errors are already stringified into the
126
+ * envelope's stdout by `Out-String -Stream`, so this only removes
127
+ * transport-level noise such as module-load progress.
128
+ * @param stderr - raw stderr text captured from the WinRS stream or the envelope.
71
129
  * @returns the stderr text without CLIXML host records.
72
130
  */
73
131
  export declare function stripClixml(stderr: string): string;
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 / base64-chunked file transfer / cluster execution, plus agent tools (winrm_list, winrm_exec, winrm_service, winrm_process, 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.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
@@ -44,25 +44,25 @@
44
44
  "ws": "^8.18.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-alpha.2",
48
- "@deepseek-ai/dsh-llm": "^0.1.7-alpha.2",
49
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-alpha.2",
50
- "@deepseek-ai/dsh-tools": "^0.1.7-alpha.2",
47
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-alpha.2 || ^0.2.0-rc.1",
48
+ "@deepseek-ai/dsh-llm": "^0.1.7-alpha.2 || ^0.2.0-rc.1",
49
+ "@deepseek-ai/dsh-system-prompt": "^0.1.7-alpha.2 || ^0.2.0-rc.1",
50
+ "@deepseek-ai/dsh-tools": "^0.1.7-alpha.2 || ^0.2.0-rc.1",
51
51
  "react": "^18.2.0",
52
52
  "react-dom": "^18.2.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@deepseek-ai/cordis": "^4.0.4",
56
56
  "@deepseek-ai/cordis-plugin-loader": "^1.0.5",
57
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
58
- "@deepseek-ai/dsh-client-ui-layout": "^0.1.7-rc.1",
59
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
60
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.1",
61
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
62
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
63
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
64
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1",
65
- "@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
57
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
58
+ "@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.2",
59
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
60
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.2",
61
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
63
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
64
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
65
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
66
66
  "@deepseek-ai/schemastery": "^3.18.4",
67
67
  "@types/node": "^22.20.0",
68
68
  "@types/react": "~18.3.1",
@@ -89,7 +89,8 @@
89
89
  "build": "tsc -p tsconfig.build.json && tsdown && node scripts/postbuild.mjs",
90
90
  "bundle": "tsdown",
91
91
  "watch": "tsdown --watch",
92
- "test": "node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs",
92
+ "test": "node --experimental-strip-types --test tests/*.test.ts",
93
+ "verify:live": "node --experimental-strip-types scripts/verify-live.mts",
93
94
  "check": "npm run typecheck && npm run build && npm test",
94
95
  "pack:check": "npm pack --dry-run --json",
95
96
  "prepack": "npm run check",