dsh-winrm 0.1.1 → 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/README.md +8 -5
- package/README.zh.md +8 -5
- package/lib/client.js +70 -70
- package/lib/index.js +206 -62
- package/lib/types/engine/client.d.ts +5 -5
- package/lib/types/powershell.d.ts +84 -26
- package/package.json +16 -15
- package/scripts/verify-live.mts +116 -0
- package/src/engine/client.ts +22 -20
- package/src/powershell.ts +194 -59
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
* any codepage output survives the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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
|
|
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
|
|
100
|
+
* Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
|
|
47
101
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
112
|
+
stdout: string;
|
|
113
|
+
stderr: string;
|
|
57
114
|
} | null;
|
|
58
115
|
/**
|
|
59
|
-
* Remove PowerShell's CLIXML host records from
|
|
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
|
|
69
|
-
* envelope
|
|
70
|
-
*
|
|
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.
|
|
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.
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-layout": "^0.
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.
|
|
60
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.
|
|
61
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.
|
|
62
|
-
"@deepseek-ai/dsh-host-webserver": "^0.
|
|
63
|
-
"@deepseek-ai/dsh-llm": "^0.
|
|
64
|
-
"@deepseek-ai/dsh-system-prompt": "^0.
|
|
65
|
-
"@deepseek-ai/dsh-tools": "^0.
|
|
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
|
|
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",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Live verification against a real WinRM host — exercises the stdin channel,
|
|
2
|
+
// the labelled envelope, the exit-code semantics, and a full transfer round
|
|
3
|
+
// trip. Unlike the unit tests this needs a reachable host, so it is a
|
|
4
|
+
// maintainer tool rather than part of `npm test`.
|
|
5
|
+
//
|
|
6
|
+
// Run from the repo root so `winrm-client` resolves:
|
|
7
|
+
// node --experimental-strip-types scripts/verify-live.mts
|
|
8
|
+
import { HostStore } from '../src/store.ts'
|
|
9
|
+
import { connOf, runScript, TRANSFER_CHUNK, uploadBuffer, downloadChunks, remoteFileSize } from '../src/engine/client.ts'
|
|
10
|
+
import { psWriteChunk, psReadChunk, psFileSize } from '../src/powershell.ts'
|
|
11
|
+
|
|
12
|
+
const store = new HostStore()
|
|
13
|
+
const entry = store.list()[0]
|
|
14
|
+
if (!entry) throw new Error('no host configured')
|
|
15
|
+
const conn = connOf(entry)
|
|
16
|
+
|
|
17
|
+
type Case = { name: string; script: string; code?: number; out?: string | RegExp; err?: string; contains?: string }
|
|
18
|
+
const cases: Case[] = [
|
|
19
|
+
{ name: 'plain', script: `Write-Output "hi"`, code: 0, out: 'hi' },
|
|
20
|
+
{ name: 'empty', script: `''`, code: 0, out: '' },
|
|
21
|
+
{ name: 'utf8', script: `Write-Output '中文测试 ✓ 𝄞'`, code: 0, out: '中文测试 ✓ 𝄞' },
|
|
22
|
+
{ name: 'out then exit 3', script: `Write-Output 'before'; exit 3`, code: 3, out: 'before' },
|
|
23
|
+
{ name: 'utf8 exit 5', script: `Write-Output '中文'; exit 5`, code: 5, out: '中文' },
|
|
24
|
+
{ name: 'throw', script: `throw 'kaboom'`, code: 1, contains: 'kaboom' },
|
|
25
|
+
{ name: 'nonterm error', script: `Get-ChildItem C:\\definitely-not-here-xyz`, code: 0, contains: 'Cannot find path' },
|
|
26
|
+
{ name: 'write-error', script: `Write-Error "werr"`, code: 0, contains: 'werr' },
|
|
27
|
+
{ name: 'cmd exit 7', script: `cmd /c exit 7`, code: 7 },
|
|
28
|
+
{ name: 'stderr only', script: `[Console]::Error.WriteLine('e1')`, code: 0, out: '', err: 'e1' },
|
|
29
|
+
{ name: 'out and err', script: `Write-Output 'o1'; [Console]::Error.WriteLine('e1')`, code: 0, out: 'o1', err: 'e1' },
|
|
30
|
+
{ name: 'flush before exit', script: `Write-Output 'before'; exit 4`, code: 4, out: 'before' },
|
|
31
|
+
{ name: 'error then exit', script: `Get-ChildItem C:\\definitely-not-here-xyz; exit 6`, code: 6, contains: 'Cannot find path' },
|
|
32
|
+
{ name: 'exit in function', script: `function f { exit 9 }; f`, code: 9 },
|
|
33
|
+
{ name: 'negative-ish LASTEXITCODE', script: `cmd /c "exit 0"`, code: 0 },
|
|
34
|
+
{ name: 'pipeline ok', script: `1..3 | ForEach-Object { $_ }`, code: 0, out: '1\r\n2\r\n3' },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
let bad = 0
|
|
38
|
+
console.log('=== edge cases ===')
|
|
39
|
+
for (const c of cases) {
|
|
40
|
+
try {
|
|
41
|
+
const r = await runScript(conn, c.script, { timeoutMs: 60_000 })
|
|
42
|
+
const problems: string[] = []
|
|
43
|
+
if (c.code !== undefined && r.exitCode !== c.code) problems.push(`code=${r.exitCode} want=${c.code}`)
|
|
44
|
+
if (typeof c.out === 'string' && r.stdout.trimEnd() !== c.out) problems.push(`out=${JSON.stringify(r.stdout)} want=${JSON.stringify(c.out)}`)
|
|
45
|
+
if (c.err !== undefined && r.stderr.trim() !== c.err) problems.push(`err=${JSON.stringify(r.stderr)} want=${JSON.stringify(c.err)}`)
|
|
46
|
+
if (c.contains !== undefined && !r.stdout.includes(c.contains)) problems.push(`missing ${JSON.stringify(c.contains)}`)
|
|
47
|
+
if (problems.length > 0) { bad++; console.log(`BAD ${c.name}: ${problems.join('; ')}`) }
|
|
48
|
+
else console.log(`ok ${c.name} [${r.durationMs}ms]`)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
bad++
|
|
51
|
+
console.log(`THREW ${c.name}: ${error instanceof Error ? error.message : String(error)}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Big output that a mid-script exit must not truncate.
|
|
56
|
+
{
|
|
57
|
+
const r = await runScript(conn, `1..2000 | ForEach-Object { "line$_" }; exit 5`, { timeoutMs: 120_000 })
|
|
58
|
+
const lines = r.stdout.trim().split(/\r?\n/).length
|
|
59
|
+
const ok = r.exitCode === 5 && lines >= 2000
|
|
60
|
+
if (!ok) bad++
|
|
61
|
+
console.log(`${ok ? 'ok ' : 'BAD '} flush big then exit: code=${r.exitCode} lines=${lines} [${r.durationMs}ms]`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Scripts far past the ~8191-char command line: the whole point of the rewrite.
|
|
65
|
+
console.log('=== large payloads (command line cannot carry these) ===')
|
|
66
|
+
for (const size of [5_000, 50_000, 200_000]) {
|
|
67
|
+
const r = await runScript(conn, `# ${'x'.repeat(size)}\nWrite-Output 'ok'`, { timeoutMs: 120_000 })
|
|
68
|
+
const ok = r.exitCode === 0 && r.stdout.trim() === 'ok'
|
|
69
|
+
if (!ok) bad++
|
|
70
|
+
console.log(`${ok ? 'ok ' : 'BAD '} script=${size} chars code=${r.exitCode} out=${JSON.stringify(r.stdout.trim())} [${r.durationMs}ms]`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Multi-byte payload: length is counted in bytes, split on code points.
|
|
74
|
+
{
|
|
75
|
+
const r = await runScript(conn, `# ${'中'.repeat(50_000)}\nWrite-Output 'ok'`, { timeoutMs: 120_000 })
|
|
76
|
+
const ok = r.exitCode === 0 && r.stdout.trim() === 'ok'
|
|
77
|
+
if (!ok) bad++
|
|
78
|
+
console.log(`${ok ? 'ok ' : 'BAD '} multibyte 150KB code=${r.exitCode} [${r.durationMs}ms]`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The transfer chunk that used to blow the command line (~65K base64 chars).
|
|
82
|
+
console.log('=== transfer chunk (previously structurally broken) ===')
|
|
83
|
+
{
|
|
84
|
+
const r = await runScript(conn, psReadChunk('C:\\Windows\\System32\\notepad.exe', 0, TRANSFER_CHUNK), { timeoutMs: 120_000 })
|
|
85
|
+
const bytes = Buffer.from(r.stdout.trim(), 'base64').length
|
|
86
|
+
const ok = r.exitCode === 0 && bytes === TRANSFER_CHUNK
|
|
87
|
+
if (!ok) bad++
|
|
88
|
+
console.log(`${ok ? 'ok ' : 'BAD '} read ${TRANSFER_CHUNK} bytes: code=${r.exitCode} decoded=${bytes} [${r.durationMs}ms]`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Real upload/download round trip well past the old limit.
|
|
92
|
+
console.log('=== upload/download round trip ===')
|
|
93
|
+
{
|
|
94
|
+
const remote = 'C:\\temp\\dsh-winrm-verify\\blob.bin'
|
|
95
|
+
const data = Buffer.alloc(200 * 1024)
|
|
96
|
+
for (let i = 0; i < data.length; i++) data[i] = (i * 31 + 7) & 0xff
|
|
97
|
+
const written = await uploadBuffer(conn, remote, data, undefined, 120_000)
|
|
98
|
+
const size = await remoteFileSize(conn, remote, 60_000)
|
|
99
|
+
const chunks: Buffer[] = []
|
|
100
|
+
const read = await downloadChunks(conn, remote, b64 => { chunks.push(Buffer.from(b64, 'base64')) }, undefined, 120_000)
|
|
101
|
+
const back = Buffer.concat(chunks)
|
|
102
|
+
const same = back.length === data.length && back.equals(data)
|
|
103
|
+
const ok = written === data.length && size === data.length && read === data.length && same
|
|
104
|
+
if (!ok) bad++
|
|
105
|
+
console.log(`${ok ? 'ok ' : 'BAD '} 200KB round trip: written=${written} size=${size} read=${read} identical=${same}`)
|
|
106
|
+
await runScript(conn, `Remove-Item -LiteralPath 'C:\\temp\\dsh-winrm-verify' -Recurse -Force -ErrorAction SilentlyContinue`, { timeoutMs: 60_000 })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Sanity: a script that never reaches the envelope must not claim success.
|
|
110
|
+
{
|
|
111
|
+
const r = await runScript(conn, `Write-Output 'ok'`, { timeoutMs: 60_000 })
|
|
112
|
+
console.log(`ok sanity re-check code=${r.exitCode} out=${JSON.stringify(r.stdout.trim())}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log(bad === 0 ? '\nALL PASS' : `\n${bad} FAILURE(S)`)
|
|
116
|
+
process.exit(bad === 0 ? 0 : 1)
|
package/src/engine/client.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
* any codepage output survives the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { Command, Shell } from 'winrm-client'
|
|
18
18
|
import type { WinHostEntry } from '../protocol.ts'
|
|
19
|
-
import { parseEnvelope, powershellCommandLine, psFileSize, psReadChunk, psWriteChunk,
|
|
19
|
+
import { parseEnvelope, powershellCommandLine, psFileSize, psReadChunk, psWriteChunk, scriptPayload, splitScriptPayload, stripClixml } from '../powershell.ts'
|
|
20
20
|
|
|
21
21
|
/** Connection parameters for one target (transport-agnostic projection of a host entry). */
|
|
22
22
|
export interface WinRMParams {
|
|
@@ -86,11 +86,11 @@ function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* One shell lifecycle: create → run the
|
|
90
|
-
* command completes → delete. Bounded by
|
|
91
|
-
* delete is fired without blocking
|
|
89
|
+
* One shell lifecycle: create → run the constant envelope script → push the
|
|
90
|
+
* payload on stdin → receive until the command completes → delete. Bounded by
|
|
91
|
+
* an overall deadline; on timeout the delete is fired without blocking.
|
|
92
92
|
* @param conn - connection parameters.
|
|
93
|
-
* @param
|
|
93
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
94
94
|
* @param timeoutMs - command execution budget measured from just after Execute.
|
|
95
95
|
* @param authMethod - the auth scheme for this attempt.
|
|
96
96
|
* @returns stdout/stderr captured from the WinRS streams.
|
|
@@ -98,7 +98,7 @@ function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
|
|
|
98
98
|
*/
|
|
99
99
|
async function attemptOnce(
|
|
100
100
|
conn: WinRMParams,
|
|
101
|
-
|
|
101
|
+
payload: string,
|
|
102
102
|
timeoutMs: number,
|
|
103
103
|
authMethod: WinrmAuth,
|
|
104
104
|
): Promise<{ stdout: string; stderr: string }> {
|
|
@@ -117,8 +117,10 @@ async function attemptOnce(
|
|
|
117
117
|
void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
|
|
118
118
|
throw new WinrmTimedOut()
|
|
119
119
|
}
|
|
120
|
-
const
|
|
121
|
-
const
|
|
120
|
+
const commandId = await Command.doExecuteCommand({ ...base, shellId, command: powershellCommandLine() })
|
|
121
|
+
for (const piece of splitScriptPayload(payload)) {
|
|
122
|
+
await Command.doSendInput({ ...base, shellId, commandId, input: piece })
|
|
123
|
+
}
|
|
122
124
|
const receive = { ...base, shellId, commandId }
|
|
123
125
|
const commandDeadline = Date.now() + timeoutMs
|
|
124
126
|
let stdout = ''
|
|
@@ -156,21 +158,21 @@ async function attemptOnce(
|
|
|
156
158
|
* succeeds. A timeout aborts immediately (retrying auth would waste the
|
|
157
159
|
* remaining budget on a reachable-but-slow host).
|
|
158
160
|
* @param conn - connection parameters.
|
|
159
|
-
* @param
|
|
161
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
160
162
|
* @param timeoutMs - command execution budget.
|
|
161
163
|
* @returns captured stdout/stderr.
|
|
162
164
|
* @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
|
|
163
165
|
*/
|
|
164
166
|
async function runWinrm(
|
|
165
167
|
conn: WinRMParams,
|
|
166
|
-
|
|
168
|
+
payload: string,
|
|
167
169
|
timeoutMs: number,
|
|
168
170
|
): Promise<{ stdout: string; stderr: string }> {
|
|
169
171
|
const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
|
|
170
172
|
let lastError: unknown
|
|
171
173
|
for (const authMethod of candidates) {
|
|
172
174
|
try {
|
|
173
|
-
return await attemptOnce(conn,
|
|
175
|
+
return await attemptOnce(conn, payload, timeoutMs, authMethod)
|
|
174
176
|
} catch (error) {
|
|
175
177
|
if (error instanceof WinrmTimedOut) throw error
|
|
176
178
|
lastError = error
|
|
@@ -186,11 +188,11 @@ export async function runScript(
|
|
|
186
188
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; durationMs: number }> {
|
|
187
189
|
const started = Date.now()
|
|
188
190
|
const timeoutMs = options.timeoutMs ?? 60_000
|
|
189
|
-
const
|
|
191
|
+
const payload = scriptPayload(script)
|
|
190
192
|
|
|
191
193
|
let outcome: { stdout: string; stderr: string }
|
|
192
194
|
try {
|
|
193
|
-
outcome = await runWinrm(conn,
|
|
195
|
+
outcome = await runWinrm(conn, payload, timeoutMs)
|
|
194
196
|
} catch (error) {
|
|
195
197
|
if (error instanceof WinrmTimedOut) {
|
|
196
198
|
return { stdout: '', stderr: '', exitCode: null, timedOut: true, durationMs: Date.now() - started }
|
|
@@ -207,10 +209,10 @@ export async function runScript(
|
|
|
207
209
|
const exitCode = parsed !== null ? parsed.exitCode : 1
|
|
208
210
|
// A script that reached the envelope leaves only the host's CLIXML records
|
|
209
211
|
// on stderr; an aborted one keeps its stderr untouched for diagnosis.
|
|
210
|
-
const stderr = parsed !== null ? stripClixml(
|
|
212
|
+
const stderr = parsed !== null ? stripClixml(parsed.stderr) : outcome.stderr
|
|
211
213
|
options.onChunk?.({ output: raw, stderr })
|
|
212
214
|
return {
|
|
213
|
-
stdout: parsed !== null ? parsed.
|
|
215
|
+
stdout: parsed !== null ? parsed.stdout : raw,
|
|
214
216
|
stderr,
|
|
215
217
|
exitCode,
|
|
216
218
|
timedOut: false,
|