dsh-pwsh-plugin 0.2.16 → 0.2.18
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/FINDINGS.md +35 -0
- package/README.md +54 -21
- package/README.zh-CN.md +45 -16
- package/cordis.patch.yml +4 -0
- package/docs/dsh-windows-schannel.zh-CN.md +118 -0
- package/docs/llrun.md +21 -1
- 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 +3 -3
- package/plugin/index.mjs +155 -193
- package/plugin/session-server.ps1 +166 -202
- package/plugin/session.mjs +160 -152
- package/wsl/custom-bash.mjs +47 -2
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
|
}
|
package/wsl/custom-bash.mjs
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* 最后才用包内自带的 wsl/llrun —— 显式安装的优先,包里那份只负责开箱可用。
|
|
23
23
|
* 取第一个可执行的;探测结果缓存,装好即生效,不必重启。
|
|
24
24
|
* fence 是否按 sandbox 权限档挂围栏,默认 true。
|
|
25
|
+
* sshAgentSocket WSL 侧的 ssh-agent socket 路径,默认 '~/.ssh/wsl2-ssh-agent.sock';
|
|
26
|
+
* 给 false 关掉这段探测。见下方「SSH agent」一节。
|
|
25
27
|
*
|
|
26
28
|
* 沙箱(2026-10-23)
|
|
27
29
|
* workspace-write / read-only 下经 `wsl/llrun` 起 bash:userns socket 掩蔽(docker 等)
|
|
@@ -40,6 +42,15 @@
|
|
|
40
42
|
* - POSIX 路径的 `workdir` 不能当 Windows 进程 cwd(spawn 直接失败);
|
|
41
43
|
* 有围栏时交给 llrun 的 `-C`(在 landlock 之后 chdir),无围栏时退化为命令内 `cd`。
|
|
42
44
|
* - 默认工作目录取自 workingDirectory 服务而非 header.cwd:会话中途切目录后 header 不更新。
|
|
45
|
+
*
|
|
46
|
+
* SSH agent
|
|
47
|
+
* 非交互 shell 不读 ~/.bashrc(那是交互式专属),所以用户终端里能用的
|
|
48
|
+
* SSH_AUTH_SOCK 在这里天然是空的。而 WSLENV 转发也不可靠:Windows 侧的
|
|
49
|
+
* 变量要显式列进 WSLENV 才过界,且经 llrun 的 userns 之后仍然没有保证。
|
|
50
|
+
* 因此改成在脚本文本最前面插一段探测:socket 存在就 export,已设过则不动。
|
|
51
|
+
* 走 stdin 送进 `bash -s`,围栏内外是同一条路径,一处覆盖两种模式。
|
|
52
|
+
* 只 export 变量,不启动守护进程 —— 进程得由 systemd 用户单元之类的
|
|
53
|
+
* 围栏外机制维持,本工具无权也不该替用户起后台服务。
|
|
43
54
|
*/
|
|
44
55
|
|
|
45
56
|
import { fileURLToPath } from 'node:url'
|
|
@@ -64,6 +75,30 @@ const DEFAULT_TIMEOUT_MS = 120000
|
|
|
64
75
|
const DEFAULT_MAX_OUTPUT_BYTES = 64000
|
|
65
76
|
const PROBE_TIMEOUT_MS = 8000
|
|
66
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 默认的 wsl2-ssh-agent socket(rupor-github/wsl-ssh-agent 的默认路径)。
|
|
80
|
+
* 用 $HOME 而不是展开后的绝对路径:脚本在 WSL 里跑,家目录以那边的为准。
|
|
81
|
+
*/
|
|
82
|
+
const DEFAULT_SSH_AGENT_SOCKET = '$HOME/.ssh/wsl2-ssh-agent.sock'
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 生成 agent 探测片段。返回空串表示不需要插入。
|
|
86
|
+
*
|
|
87
|
+
* 三件事同时成立才 export:调用方没有自己设 SSH_AUTH_SOCK、socket 确实存在、
|
|
88
|
+
* 并且是个 socket(`-S` 而不是 `-e`)—— 残留的普通文件不该被当成可用的 agent。
|
|
89
|
+
* 已设过就不覆盖:用户显式指定的优先级永远高于我们的猜测,哪怕它指错了,
|
|
90
|
+
* 也该由用户看到错误而不是被静默改到别处。
|
|
91
|
+
*/
|
|
92
|
+
export function sshAgentPreamble(socket) {
|
|
93
|
+
if (typeof socket !== 'string' || socket.length === 0) return ''
|
|
94
|
+
const quoted = '"' + socket.replaceAll('"', '\\"') + '"'
|
|
95
|
+
return [
|
|
96
|
+
'if [ -z "$SSH_AUTH_SOCK" ] && [ -S ' + quoted + ' ]; then',
|
|
97
|
+
' export SSH_AUTH_SOCK=' + quoted,
|
|
98
|
+
'fi',
|
|
99
|
+
].join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
/**
|
|
68
103
|
* 本文件同目录的 llrun,也就是随插件包一起发布的那份。
|
|
69
104
|
* 从包加载时它是真的存在(bundle 把这一行指进 node_modules),
|
|
@@ -191,6 +226,13 @@ export function apply(ctx, config) {
|
|
|
191
226
|
const configuredLlrun = typeof config?.llrunPath === 'string' && config.llrunPath.length > 0 ? config.llrunPath : undefined
|
|
192
227
|
const llrunCandidates = configuredLlrun === undefined ? defaultLlrunCandidates() : [configuredLlrun]
|
|
193
228
|
const fenceEnabled = config?.fence !== false
|
|
229
|
+
// 显式给 false 表示完全关掉;给字符串就用它;其余(含缺省)用默认 socket。
|
|
230
|
+
const sshAgentSocket = config?.sshAgentSocket === false
|
|
231
|
+
? ''
|
|
232
|
+
: typeof config?.sshAgentSocket === 'string' && config.sshAgentSocket.length > 0
|
|
233
|
+
? config.sshAgentSocket
|
|
234
|
+
: DEFAULT_SSH_AGENT_SOCKET
|
|
235
|
+
const agentPreamble = sshAgentPreamble(sshAgentSocket)
|
|
194
236
|
|
|
195
237
|
/**
|
|
196
238
|
* bash 解析:按 bashCandidates 顺序问 subprocess 服务,第一个解出来的胜出。
|
|
@@ -289,7 +331,8 @@ export function apply(ctx, config) {
|
|
|
289
331
|
const needFence = fenceEnabled && mode !== 'danger-full-access'
|
|
290
332
|
|
|
291
333
|
let argv
|
|
292
|
-
|
|
334
|
+
// agent 探测排在用户脚本之前,两条路径共用同一段文本,围栏内外行为一致。
|
|
335
|
+
let script = agentPreamble.length > 0 ? agentPreamble + '\n' + args.command : args.command
|
|
293
336
|
if (needFence) {
|
|
294
337
|
const llrunPath = await resolveLlrun(shell, baseCwd)
|
|
295
338
|
if (llrunPath === undefined) {
|
|
@@ -307,7 +350,9 @@ export function apply(ctx, config) {
|
|
|
307
350
|
fenceArgs.push(shellQuote(workspace), '/bin/bash', '-s')
|
|
308
351
|
argv = [shell, '-c', `exec ${llrunExecWord(llrunPath)} ${fenceArgs.join(' ')}`]
|
|
309
352
|
} else {
|
|
310
|
-
|
|
353
|
+
// 这里要基于已经带上前置片段的 script 再包 cd,不能回到 args.command ——
|
|
354
|
+
// 否则带 workdir 的调用会静默丢掉 agent 探测。
|
|
355
|
+
if (posixDir !== undefined) script = `cd ${shellQuote(posixDir)} && ${script}`
|
|
311
356
|
argv = [shell, '-s']
|
|
312
357
|
}
|
|
313
358
|
|