dsh-pwsh-plugin 0.2.17 → 0.2.19
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 +91 -22
- package/README.zh-CN.md +80 -17
- package/client/index.js +94 -0
- package/cordis.patch.yml +1 -0
- package/docs/dsh-windows-schannel.zh-CN.md +118 -0
- package/docs/pwsh-run-audit.zh-CN.md +183 -0
- package/docs/runtime-notes.md +23 -19
- package/docs/runtime-notes.zh-CN.md +17 -15
- package/package.json +22 -4
- package/plugin/config.mjs +13 -0
- package/plugin/index.mjs +163 -193
- package/plugin/native-pwsh-guard.mjs +43 -0
- package/plugin/session-server.ps1 +166 -202
- package/plugin/session.mjs +160 -152
package/plugin/session.mjs
CHANGED
|
@@ -1,42 +1,56 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* 本模块**不依赖 DSH**,只要求调用方给一个最小的 subprocess 接口:
|
|
5
|
-
* interface SubprocessLike {
|
|
6
|
-
* spawn(spec): { stdin, stdout, stderr, done, terminate(), waitForExit(signal?) }
|
|
7
|
-
* }
|
|
8
|
-
* 这样既能挂在 ctx.subprocess 上,也能在纯 node 里做集成测试。
|
|
9
|
-
*/
|
|
1
|
+
/** 常驻 pwsh 的协议、串行调用及有界输出收集。 */
|
|
2
|
+
import { gzipSync } from 'node:zlib'
|
|
3
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
10
4
|
|
|
11
|
-
const DEFAULT_GRACE_MS = 3000
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 单次调用的输出收集上限(每个流各自计)。
|
|
15
|
-
* 常驻会话没有原生工具那种 collect 尾巴截断,几个 MB 的输出也照单全收——不设上限的话
|
|
16
|
-
* 一段死循环打印就能把宿主进程的内存吃光。超限只丢弃后续内容并打标记,不打断脚本。
|
|
17
|
-
*/
|
|
18
5
|
export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
19
|
-
|
|
20
|
-
|
|
6
|
+
export const DIAGNOSTIC_BYTES = 64 * 1024
|
|
7
|
+
export const RESPONSE_GRACE_MS = 5000
|
|
8
|
+
// Node 定时器采用有符号 32 位毫秒,执行预算还需给收尾留下余量。
|
|
9
|
+
export const MAX_TIMEOUT_MS = 2147483647 - RESPONSE_GRACE_MS
|
|
10
|
+
const MAX_FRAME_CHARS = 32768
|
|
21
11
|
export const encodeScript = (text) => Buffer.from(text, 'utf8').toString('base64')
|
|
22
12
|
|
|
23
|
-
|
|
13
|
+
export function validateTimeout(value) {
|
|
14
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) {
|
|
15
|
+
throw new Error('pwsh_run timeoutMs must be an integer between 1 and ' + MAX_TIMEOUT_MS)
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
24
20
|
export function bootArgv(serverSource) {
|
|
25
|
-
|
|
21
|
+
// 压缩启动源码:服务端功能增长不能再撞 Windows 的 32K 命令行上限。
|
|
22
|
+
const payload = gzipSync(Buffer.from(serverSource, 'utf8')).toString('base64')
|
|
23
|
+
const boot = "$m=[IO.MemoryStream]::new([Convert]::FromBase64String('" + payload + "'));"
|
|
24
|
+
+ "$g=[IO.Compression.GZipStream]::new($m,[IO.Compression.CompressionMode]::Decompress);"
|
|
25
|
+
+ "$r=[IO.StreamReader]::new($g,[Text.Encoding]::UTF8);"
|
|
26
|
+
+ "try { & ([ScriptBlock]::Create($r.ReadToEnd())) } finally { $r.Dispose(); $m.Dispose() }"
|
|
27
|
+
const encoded = Buffer.from(boot, 'utf16le').toString('base64')
|
|
28
|
+
if (encoded.length > 30000) throw new Error('pwsh_run compressed bootstrap exceeds the Windows command-line budget')
|
|
29
|
+
return ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cancelled = () => new Error('pwsh_run cancelled before execution')
|
|
33
|
+
export function abortable(promise, signal, shouldAbort = () => true) {
|
|
34
|
+
if (signal === undefined) return promise
|
|
35
|
+
if (signal.aborted) return Promise.reject(cancelled())
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const abort = () => { if (shouldAbort()) reject(cancelled()) }
|
|
38
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
39
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clipUtf8(text, limit) {
|
|
44
|
+
const bytes = Buffer.from(text, 'utf8')
|
|
45
|
+
if (bytes.length <= limit) return text
|
|
46
|
+
return new StringDecoder('utf8').write(bytes.subarray(0, Math.max(0, limit)))
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
/**
|
|
29
|
-
* 一个常驻 pwsh 会话。
|
|
30
|
-
*
|
|
31
|
-
* 线程模型:服务端是单 runspace,调用必须串行;run() 内部用 Promise 链排队。
|
|
32
|
-
* 生命周期:进程退出 / stdout 出错 / 服务端发 BYE 都算「会话死亡」,
|
|
33
|
-
* 在途调用立刻失败,下一次 acquire 会重建。
|
|
34
|
-
*/
|
|
35
49
|
export class PwshSession {
|
|
36
|
-
constructor(handle, label, maxOutputBytes) {
|
|
50
|
+
constructor(handle, label, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES) {
|
|
37
51
|
this.handle = handle
|
|
38
52
|
this.label = label
|
|
39
|
-
this.maxOutputBytes = maxOutputBytes
|
|
53
|
+
this.maxOutputBytes = maxOutputBytes
|
|
40
54
|
this.buffer = ''
|
|
41
55
|
this.pending = new Map()
|
|
42
56
|
this.activeId = null
|
|
@@ -46,178 +60,172 @@ export class PwshSession {
|
|
|
46
60
|
this.queue = Promise.resolve()
|
|
47
61
|
this.lastUsed = Date.now()
|
|
48
62
|
this.stderrTail = ''
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (handle.done !== undefined) {
|
|
62
|
-
handle.done.then(
|
|
63
|
-
(outcome) => this.#fail('session died code=' + String(outcome === undefined ? 'null' : outcome.exitCode)),
|
|
64
|
-
(error) => this.#fail('session died: ' + String(error)),
|
|
65
|
-
)
|
|
66
|
-
}
|
|
63
|
+
this.startupWarnings = []
|
|
64
|
+
handle.stdin?.on?.('error', (error) => this.#fail('stdin error: ' + String(error)))
|
|
65
|
+
handle.stdout?.setEncoding?.('utf8')
|
|
66
|
+
handle.stdout?.on('data', (chunk) => this.#onData(chunk))
|
|
67
|
+
handle.stdout?.on('error', (error) => this.#fail('stdout error: ' + String(error)))
|
|
68
|
+
handle.stderr?.setEncoding?.('utf8')
|
|
69
|
+
handle.stderr?.on('data', (chunk) => { this.stderrTail = (this.stderrTail + chunk).slice(-2000) })
|
|
70
|
+
handle.stderr?.on('error', (error) => { this.stderrTail = (this.stderrTail + String(error)).slice(-2000) })
|
|
71
|
+
handle.done?.then(
|
|
72
|
+
(outcome) => this.#fail('session died code=' + String(outcome?.exitCode ?? 'null')),
|
|
73
|
+
(error) => this.#fail('session died: ' + String(error)),
|
|
74
|
+
)
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
/** 追加收到的字节,按行切出协议消息。 */
|
|
70
77
|
#onData(chunk) {
|
|
71
78
|
this.buffer += chunk
|
|
72
79
|
for (;;) {
|
|
73
80
|
const index = this.buffer.indexOf('\n')
|
|
74
|
-
if (index < 0)
|
|
81
|
+
if (index < 0) {
|
|
82
|
+
if (this.buffer.length > MAX_FRAME_CHARS) {
|
|
83
|
+
this.#fail('pwsh protocol frame exceeded its size limit')
|
|
84
|
+
void this.dispose()
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
75
88
|
const line = this.buffer.slice(0, index).replace(/\r$/, '')
|
|
76
89
|
this.buffer = this.buffer.slice(index + 1)
|
|
90
|
+
if (line.length > MAX_FRAME_CHARS) {
|
|
91
|
+
this.#fail('pwsh protocol frame exceeded its size limit')
|
|
92
|
+
void this.dispose()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
77
95
|
this.#onLine(line)
|
|
78
96
|
}
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
#onLine(line) {
|
|
82
|
-
if (line
|
|
83
|
-
if (line === '<<<BYE>>>') {
|
|
84
|
-
this.#fail('session said BYE')
|
|
85
|
-
return
|
|
86
|
-
}
|
|
100
|
+
if (line === '<<<BYE>>>') { this.#fail('session said BYE'); return }
|
|
87
101
|
const marker = line.match(/^<<<([BTE]):([^>:]+)(?::([01]))?>>>$/)
|
|
88
102
|
if (marker !== null) {
|
|
89
|
-
const kind = marker
|
|
90
|
-
|
|
91
|
-
if (kind === 'B') {
|
|
92
|
-
this.activeId = id
|
|
93
|
-
return
|
|
94
|
-
}
|
|
103
|
+
const [, kind, id] = marker
|
|
104
|
+
if (kind === 'B') { this.activeId = id; return }
|
|
95
105
|
const entry = this.pending.get(id)
|
|
96
106
|
if (entry === undefined) return
|
|
97
|
-
if (kind === 'T') {
|
|
98
|
-
if (marker[3] === '1') entry.response.timedOut = true
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
// E:本次响应收齐
|
|
107
|
+
if (kind === 'T') { entry.response.timedOut = marker[3] === '1'; return }
|
|
102
108
|
this.activeId = null
|
|
103
109
|
this.pending.delete(id)
|
|
104
110
|
entry.resolve(entry.response)
|
|
105
111
|
return
|
|
106
112
|
}
|
|
107
|
-
const
|
|
108
|
-
if (part === null) return
|
|
109
|
-
const entry = this.activeId === null ? undefined : this.pending.get(this.activeId)
|
|
113
|
+
const entry = this.pending.get(this.activeId)
|
|
110
114
|
if (entry === undefined) return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (part
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
115
|
+
if (line === 'L:') { entry.response.truncated = true; return }
|
|
116
|
+
const part = line.match(/^([OEWVXFSID])(\+?):(.*)$/)
|
|
117
|
+
if (part === null) return
|
|
118
|
+
let text = Buffer.from(part[3], 'base64').toString('utf8')
|
|
119
|
+
const kind = part[1]
|
|
120
|
+
if (kind === 'S') { entry.response.sentinel = text; return }
|
|
121
|
+
const diagnostic = 'EXF'.includes(kind)
|
|
122
|
+
const counter = diagnostic ? 'diagnosticBytes' : 'bytes'
|
|
123
|
+
const records = diagnostic ? 'diagnosticRecords' : 'records'
|
|
124
|
+
const newRecord = entry.continuing !== kind
|
|
125
|
+
const separator = newRecord && entry.response[records] > 0 ? 1 : 0
|
|
126
|
+
const remaining = (diagnostic ? DIAGNOSTIC_BYTES : this.maxOutputBytes) - entry.response[counter] - separator
|
|
127
|
+
if (remaining < 0) { entry.response.truncated = true; return }
|
|
128
|
+
const kept = clipUtf8(text, remaining)
|
|
129
|
+
if (kept !== text) entry.response.truncated = true
|
|
130
|
+
text = kept
|
|
131
|
+
if (text.length > 0 || part[3].length === 0) {
|
|
132
|
+
entry.response[counter] += Buffer.byteLength(text, 'utf8') + separator
|
|
133
|
+
if (newRecord) entry.response[records]++
|
|
123
134
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
case 'X': entry.response.exceptions.push(text); break
|
|
130
|
-
case 'F': entry.response.fatal.push(text); break
|
|
131
|
-
default: break
|
|
135
|
+
const field = { O: 'out', E: 'err', W: 'warn', V: 'verbose', X: 'exceptions', F: 'fatal', I: 'information', D: 'debug' }[kind]
|
|
136
|
+
const items = entry.response[field]
|
|
137
|
+
if (text.length > 0 || part[3].length === 0) {
|
|
138
|
+
if (entry.continuing === kind && items.length > 0) items[items.length - 1] += text
|
|
139
|
+
else items.push(text)
|
|
132
140
|
}
|
|
141
|
+
entry.continuing = part[2] === '+' ? kind : null
|
|
133
142
|
}
|
|
134
143
|
|
|
135
|
-
/** 会话不可再用:在途调用立刻失败。 */
|
|
136
144
|
#fail(reason) {
|
|
137
|
-
if (this.dead)
|
|
138
|
-
this.
|
|
139
|
-
this.deathReason = reason
|
|
140
|
-
for (const entry of this.pending.values()) entry.reject(new Error(reason))
|
|
145
|
+
if (!this.dead) { this.dead = true; this.deathReason = reason }
|
|
146
|
+
for (const entry of this.pending.values()) entry.reject(new Error(this.deathReason ?? reason))
|
|
141
147
|
this.pending.clear()
|
|
142
148
|
this.activeId = null
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
this.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
await this.handle.waitForExit(AbortSignal.timeout(3000))
|
|
156
|
-
} catch {
|
|
157
|
-
// 托管范围无法观察时不再等待。
|
|
158
|
-
}
|
|
159
|
-
}
|
|
151
|
+
dispose(reason = 'pwsh session disposed') {
|
|
152
|
+
this.#fail(reason)
|
|
153
|
+
this.disposal ??= (async () => {
|
|
154
|
+
try { this.handle.terminate() } catch { /* 已退出的进程不需要再次终止。 */ }
|
|
155
|
+
const abort = new AbortController()
|
|
156
|
+
const timer = setTimeout(() => abort.abort(), 3000)
|
|
157
|
+
try { await this.handle.waitForExit?.(abort.signal) } catch { /* 等待范围已终止或不可观察。 */ }
|
|
158
|
+
finally { clearTimeout(timer) }
|
|
159
|
+
})()
|
|
160
|
+
return this.disposal
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
run(script, timeoutMs, options = {}) {
|
|
164
|
+
validateTimeout(timeoutMs)
|
|
165
|
+
if (options.signal?.aborted) return Promise.reject(cancelled())
|
|
166
|
+
let started = false
|
|
167
|
+
const execute = () => { started = true; return this.#send(script, timeoutMs, options) }
|
|
165
168
|
const result = this.queue.then(execute, execute)
|
|
166
169
|
this.queue = result.then(() => undefined, () => undefined)
|
|
167
|
-
return result
|
|
170
|
+
return abortable(result, options.signal, () => !started)
|
|
168
171
|
}
|
|
169
172
|
|
|
170
|
-
#send(script, timeoutMs) {
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
if (stdin
|
|
173
|
+
#send(script, timeoutMs, options) {
|
|
174
|
+
if (options.signal?.aborted) return Promise.reject(cancelled())
|
|
175
|
+
if (this.dead) return Promise.reject(new Error(this.deathReason ?? 'session is dead'))
|
|
176
|
+
if (!this.handle.stdin) return Promise.reject(new Error('session has no stdin pipe'))
|
|
174
177
|
const id = 'c' + String(++this.sequence)
|
|
175
178
|
this.lastUsed = Date.now()
|
|
176
|
-
const response = { out: [], err: [], warn: [],
|
|
177
|
-
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000))
|
|
179
|
+
const response = { out: [], err: [], warn: this.startupWarnings.splice(0), verbose: [], information: [], debug: [], exceptions: [], fatal: [], sentinel: null, timedOut: false, bytes: 0, diagnosticBytes: 0, records: 0, diagnosticRecords: 0, truncated: false }
|
|
178
180
|
return new Promise((resolve, reject) => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
let timer
|
|
182
|
+
const finish = (fn, value) => {
|
|
183
|
+
clearTimeout(timer)
|
|
184
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
185
|
+
this.lastUsed = Date.now()
|
|
186
|
+
fn(value)
|
|
187
|
+
}
|
|
188
|
+
const stop = (isCancellation) => {
|
|
189
|
+
this.pending.delete(id)
|
|
190
|
+
response.cancelled = isCancellation
|
|
191
|
+
response.noResponse = !isCancellation
|
|
192
|
+
finish(resolve, response)
|
|
193
|
+
void this.dispose(isCancellation ? 'pwsh session cancelled' : 'pwsh session response timed out')
|
|
194
|
+
}
|
|
195
|
+
const onAbort = () => stop(true)
|
|
196
|
+
this.pending.set(id, { response, resolve: (v) => finish(resolve, v), reject: (e) => finish(reject, e), continuing: null })
|
|
197
|
+
// 只有实际发送后才开始计执行时间;排队取消不会杀前一条调用。
|
|
198
|
+
timer = setTimeout(() => stop(false), timeoutMs + RESPONSE_GRACE_MS)
|
|
199
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
200
|
+
this.handle.stdin.write(id + ' ' + timeoutMs + ' ' + encodeScript(JSON.stringify({ script, workdir: options.workdir })) + ' ' + this.maxOutputBytes + '\n', (error) => {
|
|
181
201
|
if (error) {
|
|
202
|
+
const entry = this.pending.get(id)
|
|
182
203
|
this.pending.delete(id)
|
|
183
|
-
reject(new Error('write to pwsh session failed: ' +
|
|
204
|
+
entry?.reject(new Error('write to pwsh session failed: ' + error))
|
|
184
205
|
}
|
|
185
206
|
})
|
|
186
207
|
})
|
|
187
208
|
}
|
|
188
209
|
}
|
|
189
210
|
|
|
190
|
-
/**
|
|
191
|
-
* 启动一个会话并完成握手(跑一句 $PSVersionTable 确认真的活着)。
|
|
192
|
-
* 服务端启动要解析脚本 + 打开 runspace,实测约 1s,所以握手超时给到 20s。
|
|
193
|
-
*/
|
|
194
211
|
export async function startSession(subprocess, options) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const session = new PwshSession(handle, options.label === undefined ? 'pwsh' : options.label, options.maxOutputBytes)
|
|
212
|
-
const probe = await Promise.race([
|
|
213
|
-
session.run('$PSVersionTable.PSVersion.Major', 15000),
|
|
214
|
-
new Promise((resolve) => setTimeout(() => resolve(null), 20000)),
|
|
215
|
-
])
|
|
216
|
-
if (probe === null || probe.sentinel !== 'ok') {
|
|
212
|
+
if (options.signal?.aborted) throw cancelled()
|
|
213
|
+
const argv = options.argv ?? [options.executable, ...bootArgv(options.serverSource)]
|
|
214
|
+
const handle = subprocess.spawn({ argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: 3000 })
|
|
215
|
+
const session = new PwshSession(handle, options.label ?? 'pwsh', options.maxOutputBytes)
|
|
216
|
+
let timer
|
|
217
|
+
try {
|
|
218
|
+
const probe = await Promise.race([
|
|
219
|
+
abortable(session.run('$PSVersionTable.PSVersion.Major', 15000), options.signal),
|
|
220
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('startup handshake timed out')), options.startupTimeoutMs ?? 20000) }),
|
|
221
|
+
])
|
|
222
|
+
if (probe.sentinel !== 'ok' || session.dead) throw new Error('startup handshake failed: ' + [...probe.exceptions, ...probe.fatal].join('\n'))
|
|
223
|
+
session.version = probe.out.join('\n').trim()
|
|
224
|
+
session.startupWarnings.push(...probe.warn)
|
|
225
|
+
return session
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await session.dispose('pwsh session startup failed')
|
|
217
228
|
const tail = session.stderrTail.trim()
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
session.version = (probe.out ?? []).join('\n').trim()
|
|
222
|
-
return session
|
|
229
|
+
throw new Error('pwsh session failed to start: ' + (error?.message ?? error) + (tail ? '\n' + tail : ''))
|
|
230
|
+
} finally { clearTimeout(timer) }
|
|
223
231
|
}
|