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.
@@ -1,42 +1,56 @@
1
- /**
2
- * pwsh-run 会话客户端 —— 常驻 pwsh 进程的生命周期与协议编解码
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
- /** 由 UTF-16LE 的 base64 生成 pwsh 启动参数(-EncodedCommand,避开 32K 命令行里的转义问题)。 */
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
- return ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(serverSource, 'utf16le').toString('base64')]
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 === undefined ? DEFAULT_MAX_OUTPUT_BYTES : 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
- const stdout = handle.stdout
51
- if (stdout !== undefined && stdout !== null) {
52
- if (typeof stdout.setEncoding === 'function') stdout.setEncoding('utf8')
53
- stdout.on('data', (chunk) => this.#onData(chunk))
54
- stdout.on('error', (error) => this.#fail('stdout error: ' + String(error)))
55
- }
56
- const stderr = handle.stderr
57
- if (stderr !== undefined && stderr !== null) {
58
- if (typeof stderr.setEncoding === 'function') stderr.setEncoding('utf8')
59
- stderr.on('data', (chunk) => { this.stderrTail = (this.stderrTail + chunk).slice(-2000) })
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) return
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.length === 0) return
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[1]
90
- const id = marker[2]
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 part = line.match(/^([OEWVXFS]):(.*)$/)
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
- const text = Buffer.from(part[2], 'base64').toString('utf8')
112
- // 哨兵必须无条件收:它决定「跑完 / 抛异常 / 被中断」,丢了会让上层误判。
113
- if (part[1] === 'S') {
114
- entry.response.sentinel = text
115
- return
116
- }
117
- if (entry.response.truncated) return
118
- entry.response.bytes += text.length
119
- if (entry.response.bytes > this.maxOutputBytes) {
120
- // 只标记一次;后续数据行直接丢弃,脚本本身不受影响。
121
- entry.response.truncated = true
122
- return
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
- switch (part[1]) {
125
- case 'O': entry.response.out.push(text); break
126
- case 'E': entry.response.err.push(text); break
127
- case 'W': entry.response.warn.push(text); break
128
- case 'V': entry.response.verbose.push(text); break
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) return
138
- this.dead = true
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
- /** 杀掉进程并等待托管范围清空(Windows 上 Job Object 会连带回收子进程树)。 */
146
- async dispose() {
147
- this.dead = true
148
- try {
149
- this.handle.terminate()
150
- } catch {
151
- // terminate 幂等:进程已退出时忽略。
152
- }
153
- if (typeof this.handle.waitForExit === 'function') {
154
- try {
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
- run(script, timeoutMs) {
164
- const execute = () => this.#send(script, timeoutMs)
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 (this.dead) return Promise.reject(new Error(this.deathReason === null ? 'session is dead' : this.deathReason))
172
- const stdin = this.handle.stdin
173
- if (stdin === undefined || stdin === null) return Promise.reject(new Error('session has no stdin pipe'))
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: [], verbose: [], exceptions: [], fatal: [], sentinel: null, timedOut: false, bytes: 0, truncated: false }
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
- this.pending.set(id, { response, resolve, reject })
180
- stdin.write(id + ' ' + String(seconds) + ' ' + encodeScript(script) + '\n', (error) => {
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: ' + String(error)))
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
- // argv:调用方已经算好的完整命令行。沙箱给出的是一条完整受限命令(node runner.js … -- pwsh.exe …),
196
- // 拆成前缀再接会更脆,所以整条用;没有沙箱时由调用方给出裸 pwsh 命令行。
197
- const argv = Array.isArray(options.argv) ? options.argv : [options.executable].concat(bootArgv(options.serverSource))
198
- const handle = subprocess.spawn({
199
- argv,
200
- cwd: options.cwd,
201
- stdio: {
202
- stdin: 'pipe',
203
- // 必须自己解协议:collect 模式只保留尾部,会把前面的响应截掉。
204
- stdout: 'pipe',
205
- // 同理用 pipe 而非 collect:collect 模式下 handle.stderr 是 undefined,
206
- // 启动失败的诊断尾巴会永远读不到(实测踩过)。这里自己收 2000 字符尾巴。
207
- stderr: 'pipe',
208
- },
209
- graceMs: DEFAULT_GRACE_MS,
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
- await session.dispose()
219
- throw new Error('pwsh session failed to start' + (tail.length > 0 ? ': ' + tail : ''))
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
  }
@@ -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
- let script = args.command
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
- if (posixDir !== undefined) script = `cd ${shellQuote(posixDir)} && ${args.command}`
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