dsh-winrm 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -44,6 +44,53 @@ export declare function testConnection(conn: WinRMParams): Promise<{
44
44
  error?: string;
45
45
  }>;
46
46
  export declare const TRANSFER_CHUNK: number;
47
- export declare function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs?: number): Promise<number>;
47
+ /**
48
+ * Size of a remote file, or null when the path does not exist or is a
49
+ * directory. The distinction matters: a caller that treats "absent" as "empty"
50
+ * writes an empty local file and reports success for a download that copied
51
+ * nothing.
52
+ */
53
+ export declare function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs?: number): Promise<number | null>;
48
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>;
49
55
  export declare function uploadBuffer(conn: WinRMParams, remotePath: string, data: Buffer, onProgress?: (transferred: number, total: number) => void, timeoutMs?: number): Promise<number>;
56
+ /**
57
+ * Upload a buffer over a single WinRS stdin stream.
58
+ *
59
+ * This replaces the per-chunk shell cycle: one shell, one command and one
60
+ * `Send` per 256 KiB, against a receiver that drains the stream straight into
61
+ * the file. Measured at ~10 s for 2 MiB where the old path took 310 s.
62
+ *
63
+ * Returns null — rather than throwing — when the transport this path depends
64
+ * on is unavailable (a future `winrm-client` layout that drops the internal
65
+ * module it borrows), so the caller can fall back to {@link uploadBuffer}
66
+ * instead of failing the transfer.
67
+ * @param conn - connection parameters.
68
+ * @param remotePath - destination path on the target.
69
+ * @param data - the file contents to send.
70
+ * @param onProgress - called with cumulative bytes after each frame.
71
+ * @param timeoutMs - whole-transfer budget.
72
+ * @returns bytes written, or null when the streaming path is unavailable.
73
+ */
74
+ export declare function uploadStream(conn: WinRMParams, remotePath: string, data: Buffer, onProgress?: (transferred: number, total: number) => void, timeoutMs?: number): Promise<number | null>;
75
+ /**
76
+ * Stream a remote file to a consumer, in parallel byte ranges.
77
+ *
78
+ * The old path asked for 48 KiB per shell lifecycle, so a 2 MiB file cost 43
79
+ * round-trip cycles and 129 s. Measured against this target, one `Receive`
80
+ * returns at most 128 KiB no matter how large an envelope is declared, which
81
+ * makes a download latency-bound rather than bandwidth-bound; the fix is
82
+ * therefore to run several ranges at once. Each worker opens its own shell and
83
+ * streams its range gzip-compressed, and because ranges are absolute, the
84
+ * caller can place each piece by offset and completion order does not matter.
85
+ *
86
+ * The remote side compresses unconditionally: a compressible 16 MiB file
87
+ * arrives in one round trip (2.0 s), and an incompressible file was measured no
88
+ * slower than raw (9457 ms vs 9582 ms).
89
+ * @param conn - connection parameters.
90
+ * @param remotePath - file to read.
91
+ * @param onData - receives each decompressed piece with its absolute file offset.
92
+ * @param onProgress - receives cumulative bytes delivered and the total size.
93
+ * @param timeoutMs - budget for the whole download.
94
+ * @returns the number of bytes delivered, or null when streaming is unavailable.
95
+ */
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>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Raw WSMan `Send` for the streaming upload path.
3
+ *
4
+ * The bundled `winrm-client` can only put text on the WinRS stdin stream: its
5
+ * `doSendInput` base64-encodes the input *again* on top of whatever the caller
6
+ * already encoded, and it has no way to close the stream. Both are fatal for a
7
+ * bulk transfer — double base64 costs 1.78x the bytes on the wire, and a
8
+ * stdin stream that never ends cannot tell the remote receiver "that is all".
9
+ *
10
+ * This module therefore builds the `Send` envelope itself and posts it through
11
+ * the library's own `sendHttp`, which keeps Basic/NTLM/SPNEGO and the HTTPS
12
+ * options exactly as the rest of the transport uses them. The stream carries
13
+ * raw file bytes, so the wire cost is 1.0x plus the envelope.
14
+ *
15
+ * The library is reached through `createRequire` rather than a static import
16
+ * because `dist/src/utils/http.js` is an internal CommonJS path with no
17
+ * `exports` map behind it. `loadSendHttp` returns null when that path is gone
18
+ * (a future `winrm-client` layout change), which lets the caller fall back to
19
+ * the old chunked path instead of failing the transfer.
20
+ */
21
+ /** Connection parameters for one target (mirrors `WinRMParams` in client.ts). */
22
+ export interface WsmanTarget {
23
+ host: string;
24
+ port: number;
25
+ path: string;
26
+ username: string;
27
+ password: string;
28
+ authMethod: 'basic' | 'ntlm';
29
+ useHttps?: boolean;
30
+ rejectUnauthorized?: boolean;
31
+ }
32
+ /** `sendHttp` as the bundled library declares it. */
33
+ type SendHttp = (data: string, host: string, port: number, path: string, username: string, password: string, authMethod: 'basic' | 'ntlm', timeout?: number, useHttps?: boolean, rejectUnauthorized?: boolean) => Promise<unknown>;
34
+ /**
35
+ * Envelope ceiling declared on every Send, in bytes.
36
+ *
37
+ * This is what the *client* promises the server it will never exceed, and the
38
+ * server enforces its own `MaxEnvelopeSizekb` on top. 512000 is the value the
39
+ * target host reports, and it is far above the library's hardcoded 153600 —
40
+ * raising it is what makes 256 KiB chunks legal.
41
+ */
42
+ export declare const WS_MAX_ENVELOPE_BYTES = 512000;
43
+ /**
44
+ * Bytes of XML wrapper around one base64 chunk.
45
+ *
46
+ * Measured against the envelope this module emits: fixed header, selector set,
47
+ * body tags and the message id. Used to size chunks against the ceiling before
48
+ * sending, so a normal transfer never needs a size rejection to converge.
49
+ */
50
+ export declare const SEND_ENVELOPE_OVERHEAD_BYTES = 1600;
51
+ /** The largest raw slice whose base64 form still fits `WS_MAX_ENVELOPE_BYTES`. */
52
+ export declare const MAX_SEND_SLICE_BYTES: number;
53
+ /**
54
+ * Resolve the library's `sendHttp`, or null when the internal path is absent.
55
+ * @returns the send function, or null when it cannot be loaded.
56
+ */
57
+ export declare function loadSendHttp(): SendHttp | null;
58
+ /**
59
+ * Build one WSMan `Send` envelope carrying `chunk` on the stdin stream.
60
+ *
61
+ * The header mirrors what the library emits, with three deliberate changes:
62
+ * `MaxEnvelopeSize` is raised to `limit`, the `Send` action replaces the
63
+ * command's, and the stream body is the caller's base64 verbatim — no second
64
+ * encode. `Name="stdin"` and `CommandId` are what bind the bytes to the
65
+ * running receiver's standard input.
66
+ * @param shellId - target shell from `Shell.doCreateShell`.
67
+ * @param commandId - target command from `Command.doExecuteCommand`.
68
+ * @param chunk - raw bytes to place on the remote stdin stream.
69
+ * @param limit - envelope ceiling to declare.
70
+ * @returns the SOAP envelope as a UTF-8 string.
71
+ */
72
+ export declare function buildSendEnvelope(shellId: string, commandId: string, chunk: Buffer, limit: number): string;
73
+ /**
74
+ * Push one raw slice onto the remote stdin stream.
75
+ *
76
+ * A rejection here is safe to retry with a smaller slice: the server validates
77
+ * the envelope size before dispatching it to the shell, so nothing reached the
78
+ * receiver's stdin when the call fails on size.
79
+ * @param target - connection parameters (auth already selected).
80
+ * @param shellId - target shell.
81
+ * @param commandId - target command.
82
+ * @param chunk - raw bytes to send.
83
+ * @param sendHttp - the resolved library function.
84
+ * @param timeoutMs - per-request HTTP timeout.
85
+ */
86
+ export declare function sendStdinChunk(target: WsmanTarget, shellId: string, commandId: string, chunk: Buffer, sendHttp: SendHttp, timeoutMs: number): Promise<void>;
87
+ /**
88
+ * Whether a failed Send was refused for exceeding the envelope ceiling.
89
+ *
90
+ * WSMan reports this as a SOAP fault rather than a status code, and the exact
91
+ * wording differs between the HTTP and HTTPS stacks, so the test looks for any
92
+ * of the size-related phrasings plus the plain 413 status. It deliberately
93
+ * does not match a bare "size" in unrelated text.
94
+ * @param error - the rejection to classify.
95
+ * @returns true when retrying with a smaller slice is the right response.
96
+ */
97
+ export declare function isEnvelopeTooLarge(error: unknown): boolean;
98
+ /**
99
+ * Build one WSMan `Receive` envelope asking for stdout and stderr.
100
+ *
101
+ * The library's own `buildReceiveOutputRequest` cannot be reused here because
102
+ * it hardcodes `MaxEnvelopeSize` at 153600 in its shared header. Measured
103
+ * against this target, that ceiling is what caps each response at 128 KiB —
104
+ * raising it to 512000 lets one `Receive` return 128 KiB per stream, which is
105
+ * the most the server will put in a single response regardless.
106
+ * @param shellId - target shell.
107
+ * @param commandId - target command whose streams are wanted.
108
+ * @param limit - envelope ceiling to declare.
109
+ * @returns the SOAP envelope as a UTF-8 string.
110
+ */
111
+ export declare function buildReceiveEnvelope(shellId: string, commandId: string, limit: number): string;
112
+ /** One decoded `rsp:Stream` element from a Receive response. */
113
+ export interface ReceivedStream {
114
+ /** `Name` attribute: `stdout` or `stderr`. */
115
+ name: string;
116
+ /** Decoded payload bytes; empty for a stream element that carried no text. */
117
+ data: Buffer;
118
+ /** True when the `End` attribute marks this stream closed. */
119
+ end: boolean;
120
+ }
121
+ /** A decoded Receive response: its streams plus the command's exit code once known. */
122
+ export interface ReceiveResult {
123
+ streams: ReceivedStream[];
124
+ /** `rsp:CommandState.rsp:ExitCode`, or undefined while the command still runs. */
125
+ exitCode?: number;
126
+ }
127
+ /**
128
+ * Read the streams and exit code out of a Receive response.
129
+ *
130
+ * The library's `extractStreams` cannot be used for binary payloads: it decodes
131
+ * every stream through `extractText`, which falls back to `String(obj)` for an
132
+ * element that has attributes but no text node — turning the terminal
133
+ * `End="true"` marker into the literal string `'[object Object]'` — and the
134
+ * library's `doReceiveOutputNonBlocking` then `.trim()`s the decoded text,
135
+ * which would eat whitespace bytes at either end of a real payload. This
136
+ * reader keeps the distinction between "no text" (zero bytes) and "text" and
137
+ * never trims.
138
+ * @param response - the parsed XML object returned by `sendHttp`.
139
+ * @returns the decoded streams and exit code.
140
+ * @throws when a stream's text node cannot be decoded as base64.
141
+ */
142
+ export declare function readReceiveResult(response: unknown): ReceiveResult;
143
+ /**
144
+ * Fetch one Receive response for a running command.
145
+ * @param target - connection parameters (auth already selected).
146
+ * @param shellId - target shell.
147
+ * @param commandId - target command.
148
+ * @param sendHttp - the resolved library function.
149
+ * @param timeoutMs - per-request HTTP timeout.
150
+ * @returns the decoded streams and exit code.
151
+ */
152
+ export declare function receiveOutput(target: WsmanTarget, shellId: string, commandId: string, sendHttp: SendHttp, timeoutMs: number): Promise<ReceiveResult>;
153
+ export {};
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The WinRM engine facade: host lookup, PowerShell exec (UTF-8 envelope),
3
- * service & process management, directory listing, base64-chunked file
4
- * transfer, cluster execution and the streaming console. The heavy lifting
5
- * lives in engine/ (client, console); this class composes them behind one
6
- * WinRmEngine instance per plugin apply.
3
+ * service & process management, directory listing, file transfer, cluster
4
+ * execution and the streaming console. The heavy lifting lives in engine/
5
+ * (client, console); this class composes them behind one WinRmEngine instance
6
+ * per plugin apply.
7
7
  */
8
8
  import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts';
9
9
  import type { HostStore } from './store.ts';
@@ -1,28 +1,89 @@
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.
16
56
  */
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;
57
+ export declare function powershellCommandLine(): string;
58
+ /**
59
+ * The constant powershell.exe command line for a streaming upload.
60
+ *
61
+ * The receiver travels on stdin right behind it, so this stays well clear of
62
+ * cmd.exe's cap and does not change when the receiver does.
63
+ */
64
+ export declare function streamCommandLine(): string;
65
+ /**
66
+ * The stdin payload for one command: the UTF-8 **byte** count, a newline, then
67
+ * the raw script.
68
+ *
69
+ * The count is in bytes, not characters, because the outer script sizes its
70
+ * receive buffer from it — a script with multi-byte characters would otherwise
71
+ * be truncated mid-character.
72
+ * @param userScript - the PowerShell script to run.
73
+ * @returns the payload to send on WinRS stdin.
74
+ */
75
+ export declare function scriptPayload(userScript: string): string;
76
+ /**
77
+ * Split a payload into Send-sized pieces without cutting a surrogate pair.
78
+ *
79
+ * `Command.doSendInput` encodes each piece to UTF-8 independently, so a lone
80
+ * surrogate at a chunk boundary would be replaced with U+FFFD and corrupt the
81
+ * script. Iterating with `for…of` yields whole code points, so a pair always
82
+ * stays in one piece.
83
+ * @param payload - the full stdin payload.
84
+ * @returns the pieces to send in order; never empty.
85
+ */
86
+ export declare function splitScriptPayload(payload: string): string[];
26
87
  /** JSON: full service table (CIM). */
27
88
  export declare function psListServices(): string;
28
89
  /** JSON: one service after an action. */
@@ -33,7 +94,12 @@ export declare function psListProcesses(): string;
33
94
  export declare function psKillProcess(id: number): string;
34
95
  /** JSON: directory listing. */
35
96
  export declare function psListDir(dir: string): string;
36
- /** Plain: remote file size in bytes (0 when missing). */
97
+ /**
98
+ * Plain: remote file size in bytes. Emits nothing at all when the path is
99
+ * missing or is a directory, so the caller can tell "absent" from "empty" —
100
+ * both used to collapse to the string "0", which made a download of a
101
+ * nonexistent file look like a successful transfer of a zero-byte one.
102
+ */
37
103
  export declare function psFileSize(p: string): string;
38
104
  /** Plain: base64 of up to `count` bytes at `offset` (download chunk). */
39
105
  export declare function psReadChunk(p: string, offset: number, count: number): string;
@@ -42,21 +108,121 @@ export declare function psReadChunk(p: string, offset: number, count: number): s
42
108
  * prints the number of bytes written.
43
109
  */
44
110
  export declare function psWriteChunk(p: string, b64: string, append: boolean): string;
111
+ /** Magic stamped into the frame header that precedes every stream chunk. */
112
+ export declare const STREAM_FRAME_MAGIC = "DSHZ";
113
+ /** Frame header size: 4 magic bytes + 4 little-endian length bytes + 1 mode byte + 1 reserved. */
114
+ export declare const STREAM_FRAME_HEADER_BYTES = 10;
115
+ /** Frame mode: the payload after the header is a gzip member. */
116
+ export declare const STREAM_FRAME_GZIP = 1;
117
+ /** Frame mode: the payload after the header is raw file bytes. */
118
+ export declare const STREAM_FRAME_RAW = 0;
119
+ /** Frame mode: the payload after the header is the transfer's metadata. */
120
+ export declare const STREAM_FRAME_HEADER = 2;
121
+ /**
122
+ * Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
123
+ *
124
+ * The frame is what makes a compressed stream legal on a pipe. stdin cannot
125
+ * seek, so the receiver has to be handed the exact byte count of the next gzip
126
+ * member before it lets `GZipStream` touch it; otherwise the inflater would
127
+ * read into the member that follows and the stream would desynchronize. The
128
+ * mode byte travels with the payload so the client can switch between gzip and
129
+ * raw per chunk.
130
+ * @param payload - gzip member bytes or raw file bytes.
131
+ * @param mode - `STREAM_FRAME_GZIP` or `STREAM_FRAME_RAW`.
132
+ * @returns the framed chunk to place on the stdin stream.
133
+ */
134
+ export declare function buildStreamFrame(payload: Buffer, mode: number): Buffer;
135
+ /**
136
+ * Streaming receiver: drain framed stdin chunks into the file named by the
137
+ * header frame, until the declared byte count lands.
138
+ *
139
+ * This is the fast upload path. Instead of shipping base64 text inside a
140
+ * PowerShell script — which costs one full shell create/command/delete cycle
141
+ * per 48 KiB — the remote shell starts this script once and the client then
142
+ * pushes bytes straight down the WinRS stdin stream. The only per-chunk cost
143
+ * left is a single `Send`, so a whole file costs one shell and one command no
144
+ * matter how large it is.
145
+ *
146
+ * The script deliberately takes **no arguments**. The destination path and
147
+ * total byte count arrive as the first frame on stdin, which keeps the command
148
+ * line a constant size: WinRS hands the command line to cmd.exe, whose limit
149
+ * is about 8191 characters, and a long remote path interpolated into the
150
+ * script would eat into that budget (the script is base64-of-UTF-16 on the
151
+ * wire, so every character costs 2.67). Reading them from the stream removes
152
+ * the limit entirely and mirrors how `powershellCommandLine` stays constant.
153
+ *
154
+ * Every wire chunk is a frame: `DSHZ` + 4-byte little-endian payload length +
155
+ * 1 mode byte + 1 reserved, followed by exactly that many payload bytes. The
156
+ * explicit frame is what makes a *pipe* usable for a compressed stream: stdin
157
+ * cannot seek, so the receiver must be told how long the next gzip member is
158
+ * before it hands the bytes to `GZipStream`, or the inflater would read into
159
+ * the member that follows. The mode byte lets the client pick per chunk — gzip
160
+ * when it actually shrinks the data, raw bytes when it would not — so
161
+ * incompressible files never pay compression overhead.
162
+ *
163
+ * The script prints the three-line envelope on stdout with the byte count as
164
+ * its payload, so the caller can prove the transfer landed complete.
165
+ * @returns a PowerShell script suitable for `-EncodedCommand`.
166
+ */
167
+ export declare function psReceiveStream(): string;
168
+ /**
169
+ * Streaming sender: write one byte range of the file to standard output,
170
+ * gzip-compressed, for the download path.
171
+ *
172
+ * This is the mirror of {@link psReceiveStream}. The old download path asked
173
+ * for 48 KiB at a time, and every chunk cost a full shell create / command /
174
+ * delete cycle — five HTTP round trips each — so a 2 MiB file took 129 s. Here
175
+ * the shell is created once and the client then simply receives; the only
176
+ * per-chunk cost left is one `Receive`, and the target streams the file as fast
177
+ * as the transport will drain it.
178
+ *
179
+ * Two details make this work:
180
+ *
181
+ * - **Only stderr may carry text.** Anything written to the PowerShell success
182
+ * stream would be merged into the payload and corrupt the file, so the byte
183
+ * count and any error go to `[Console]::Error` and the exit code, not stdout.
184
+ * - **The range is positional.** `Position` plus a byte count lets several
185
+ * shells serve different parts of one file concurrently; each writes to its
186
+ * own offset on the client, so completion order does not matter.
187
+ *
188
+ * Output is gzip-compressed unconditionally. Measured against this target, a
189
+ * compressible 16 MiB file collapsed from 121 round trips to 1 (2.0 s), and an
190
+ * incompressible 2 MiB file was no slower than raw (9457 ms vs 9582 ms), so the
191
+ * compression pays for itself or costs nothing.
192
+ * @param remotePath - file to read on the target.
193
+ * @param start - first byte offset of this worker's range.
194
+ * @param length - number of bytes this worker must send.
195
+ * @returns a PowerShell script suitable for `-EncodedCommand`.
196
+ */
197
+ export declare function psSendStream(remotePath: string, start: number, length: number): string;
198
+ /**
199
+ * Wrap an arbitrary script in the constant powershell.exe invocation.
200
+ *
201
+ * Only safe for scripts that fit cmd.exe's ~8191 character command line (the
202
+ * script is base64-of-UTF-16 on the wire, so it costs 2.67 characters each);
203
+ * {@link psReceiveStream} outgrew that and travels on stdin instead.
204
+ * @param script - the PowerShell script to run.
205
+ * @returns the command line to hand to WinRS.
206
+ */
207
+ export declare function scriptCommandLine(script: string): string;
45
208
  /**
46
- * Parse the envelope output: { exitCode, text } or null when absent.
209
+ * Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
47
210
  *
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.
211
+ * Fields are located by their `O:` / `E:` labels rather than by splitting on
212
+ * newlines, because the WinRM client trims every response chunk and can drop
213
+ * the newline that separated two lines. base64's alphabet has no colon, so a
214
+ * label can only ever be a delimiter and the search cannot land on payload
215
+ * data. The exit code is signed because `$LASTEXITCODE` can be negative.
216
+ * @param output - the raw stdout accumulated from the WinRS stdout stream.
217
+ * @returns the decoded envelope, or null when the marker is missing.
53
218
  */
54
219
  export declare function parseEnvelope(output: string): {
55
220
  exitCode: number;
56
- text: string;
221
+ stdout: string;
222
+ stderr: string;
57
223
  } | null;
58
224
  /**
59
- * Remove PowerShell's CLIXML host records from the WinRS stderr stream.
225
+ * Remove PowerShell's CLIXML host records from a stderr stream.
60
226
  *
61
227
  * PowerShell marks its stderr stream with a `#< CLIXML` header line and
62
228
  * serializes the remote host's own progress/verbose records into `<Objs>`
@@ -65,9 +231,10 @@ export declare function parseEnvelope(output: string): {
65
231
  * header and a record block (for example `[Console]::Error`) is real output,
66
232
  * not part of the CLIXML payload.
67
233
  *
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.
234
+ * The script's own PowerShell errors are already stringified into the
235
+ * envelope's stdout by `Out-String -Stream`, so this only removes
236
+ * transport-level noise such as module-load progress.
237
+ * @param stderr - raw stderr text captured from the WinRS stream or the envelope.
71
238
  * @returns the stderr text without CLIXML host records.
72
239
  */
73
240
  export declare function stripClixml(stderr: string): string;
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * SMB transfer bridge.
3
3
  *
4
- * The WinRM base64 chunk bridge is reliable for small files but breaks for
5
- * large artifacts (the WinRM/HTTP envelope rejects large payloads), so whole
6
- * files ride the remote admin share. This module mounts the remote host's
7
- * admin share with `net use` (password passed via stdin, never argv), copies
8
- * whole files with node:fs, then verifies the SHA-256 on both ends.
4
+ * SMB is the fast channel when the admin share is reachable, so `auto` tries it
5
+ * first and falls back to the WinRM streaming path. This module mounts the
6
+ * remote host's admin share with `net use` (password passed via stdin, never
7
+ * argv), copies whole files with node:fs, then verifies the SHA-256 on both
8
+ * ends.
9
9
  */
10
10
  import type { WinHostEntry } from './protocol.ts';
11
11
  /** Per-transfer channel selector. */
@@ -19,6 +19,25 @@ export interface SmbTransferResult {
19
19
  * D:\\dir\\file -> \\\\host\\D$\\dir\\file
20
20
  */
21
21
  export declare function adminSharePath(host: string, remotePath: string): string;
22
+ /**
23
+ * Whether the target's SMB port (445) accepts a connection.
24
+ *
25
+ * `net use` is the only way to mount the admin share, but it has no useful
26
+ * timeout: against a host whose 445 is filtered or whose SMB service is down it
27
+ * spends a full minute before failing. That minute was being paid on *every*
28
+ * `auto` upload before the WinRM fallback could even start — measured at 61 s
29
+ * and 58 s on the test host, against a 12 s streaming transfer. A bare TCP
30
+ * connect answers the same question in milliseconds.
31
+ *
32
+ * A negative verdict is cached briefly so a burst of transfers does not re-probe
33
+ * for each one; a positive verdict is cached too, but the `net use` that follows
34
+ * still reports its own failure if the share turns out to be unmountable.
35
+ * @param host - target hostname or address.
36
+ * @returns true when port 445 accepted a connection.
37
+ */
38
+ export declare function smbReachable(host: string): Promise<boolean>;
39
+ /** Error thrown when SMB was requested or probed and the target is not listening. */
40
+ export declare function smbUnavailable(host: string): Error;
22
41
  /**
23
42
  * Copy one local file to a remote Windows host over SMB, verifying the
24
43
  * remote SHA-256 matches the local source.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-winrm",
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.1",
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 / streamed 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.3.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",