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/plugin/index.mjs CHANGED
@@ -2,9 +2,8 @@
2
2
  * pwsh-run —— 常驻 PowerShell 会话工具(插件行)
3
3
  *
4
4
  * 为什么需要它
5
- * DSH 的 bash 工具把命令包成 /bin/bash -c "<cmd>" 再交给 shell,
6
- * 所以任何经过 bash 的 PowerShell 命令,$变量 会先被外层双引号展开、
7
- * heredoc 也会在那一层失效(实测:pwsh -Command "Write-Output \"var=$abc\"" 输出 var=)。
5
+ * 把 PowerShell 再包进其它 shell 会增加一层变量与引号解析,
6
+ * 复杂脚本因此容易在送到 PowerShell 之前改变含义。
8
7
  * 原生 pwsh 工具本身不吃 $,但受 Windows 命令行长度限制(实测 32700 字符即 ENAMETOOLONG),
9
8
  * 且每次都是全新进程,变量 / 当前目录 / 已导入模块不跨调用保留。
10
9
  *
@@ -19,7 +18,7 @@
19
18
  *
20
19
  * 沙箱:常驻进程在**启动时**整体包进 ctx.sandbox.confine 返回的受限 argv,
21
20
  * 之后它的每一次读/写都受同一个 workspace 边界约束(ACL 受限令牌是进程级,
22
- * 不是逐命令级——这一点与原生工具等价,实测 workspace 外写入全部被拒)。
21
+ * 不是逐命令级;因此每次复用前核对策略、工作区与后端,变化就重建)。
23
22
  * read-only 模式下 pwsh 以 ConstrainedLanguage 运行,无法构造 runspace,
24
23
  * 因此常驻会话在该模式下不启动,直接报错让模型改用原生 pwsh 工具。
25
24
  * ctx.sandboxPolicy 缺席时同样拒绝启动(fail-closed),除非显式 config.allowUnconfined: true。
@@ -30,7 +29,9 @@
30
29
  */
31
30
 
32
31
  import { readFileSync } from 'node:fs'
33
- import { DEFAULT_MAX_OUTPUT_BYTES, bootArgv, startSession } from './session.mjs'
32
+ export { Config } from './config.mjs'
33
+ import { installNativePwshGuard } from './native-pwsh-guard.mjs'
34
+ import { DEFAULT_MAX_OUTPUT_BYTES, MAX_TIMEOUT_MS, bootArgv, startSession, validateTimeout, abortable } from './session.mjs'
34
35
 
35
36
  /** 加载器诊断里显示的插件名。 */
36
37
  export const name = 'pwsh-run'
@@ -41,9 +42,6 @@ export const inject = ['subprocess', 'tools']
41
42
  const DEFAULT_TIMEOUT_MS = 120000
42
43
  const DEFAULT_IDLE_MS = 30 * 60 * 1000
43
44
  const DEFAULT_MAX_SESSIONS = 4
44
- /** 调用方超时之外多给的余量:服务端被打断后还要把尾部输出吐完、写结束标记。 */
45
- const RESPONSE_GRACE_MS = 5000
46
-
47
45
  /** 服务端脚本:随插件分发,启动时经 -EncodedCommand 送入,不落地临时文件。 */
48
46
  const SERVER_SOURCE = readFileSync(new URL('./session-server.ps1', import.meta.url), 'utf8')
49
47
 
@@ -59,7 +57,9 @@ const parameters = {
59
57
  description: 'Optional Windows working directory to Set-Location into before running (e.g. D:\\proj). It persists for later calls in the same session; an invalid path fails the call before the script runs.',
60
58
  },
61
59
  timeoutMs: {
62
- type: 'number',
60
+ type: 'integer',
61
+ minimum: 1,
62
+ maximum: MAX_TIMEOUT_MS,
63
63
  description: 'Timeout for this call in milliseconds; defaults to 120000. On timeout the session process is terminated and rebuilt.',
64
64
  },
65
65
  },
@@ -69,32 +69,17 @@ const parameters = {
69
69
 
70
70
  /**
71
71
  * 解析子进程的启动目录。
72
- * 与 presets/shared/custom-bash.mjs 同款:workingDirectory 服务优先(会话中途切目录后
72
+ * 与 wsl/custom-bash.mjs 同款:workingDirectory 服务优先(会话中途切目录后
73
73
  * header.cwd 不会更新),服务缺失时退回 header.cwd,再退回进程 cwd。
74
74
  */
75
75
  async function resolveCwd(ctx, agent, signal) {
76
- try {
77
- const service = ctx.get('workingDirectory')
78
- if (service !== undefined && agent !== undefined) return await service.ensure(agent, signal)
79
- } catch {
80
- // 服务异常时退回旧路径,不让一次目录查询拖垮工具调用。
81
- }
76
+ const service = ctx.get('workingDirectory')
77
+ if (service !== undefined && agent !== undefined) return await service.ensure(agent, signal)
82
78
  const header = agent?.session?.header
83
79
  if (typeof header?.cwd === 'string' && header.cwd.length > 0) return header.cwd
84
80
  return process.cwd()
85
81
  }
86
82
 
87
- /**
88
- * 把一个字符串安全地拼进 PowerShell 源码。
89
- * 必须用单引号字面量:JSON 的双引号写法会被 PowerShell 当成可展开字符串,
90
- * 路径里一旦出现 $ 或反引号(如 D:\$Recycle.Bin)就会被解析掉,
91
- * 而且 JSON 会把反斜杠加倍成 \\,与 PowerShell 的转义规则并不一致。
92
- * PowerShell 单引号里唯一需要转义的字符是单引号本身(写成两个)。
93
- */
94
- function psLiteral(value) {
95
- return "'" + String(value).replaceAll("'", "''") + "'"
96
- }
97
-
98
83
  /**
99
84
  * confine 失败时把原始 Win32 错误翻译成能照着做的说明。
100
85
  * 最常见的一种:工作区根的 DACL 没给 WRITE_DAC / WRITE_OWNER。沙箱要在目录上写 DACL 与
@@ -138,22 +123,19 @@ export function describeExecDenial(text) {
138
123
  + ' detaches the child std handles, so session corruption is prevented — the lost output is not.]'
139
124
  }
140
125
 
141
- /** 合并调用方取消信号与本地超时信号。 */
142
- function withTimeout(signal, timeoutMs) {
143
- const timeout = AbortSignal.timeout(timeoutMs)
144
- return signal === undefined ? timeout : AbortSignal.any([signal, timeout])
145
- }
146
-
147
126
  export function apply(ctx, config) {
148
127
  const pwshPath = typeof config?.pwshPath === 'string' && config.pwshPath.length > 0 ? config.pwshPath : 'pwsh'
149
- const defaultTimeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
128
+ const defaultTimeoutMs = config?.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : validateTimeout(config.timeoutMs)
150
129
  const idleMs = Number.isSafeInteger(config?.idleMs) && config.idleMs > 0 ? config.idleMs : DEFAULT_IDLE_MS
151
130
  const maxSessions = Number.isSafeInteger(config?.maxSessions) && config.maxSessions > 0 ? config.maxSessions : DEFAULT_MAX_SESSIONS
152
- const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
131
+ const maxOutputBytes = config?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES
132
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 2147483647) {
133
+ throw new Error('pwsh_run maxOutputBytes must be an integer between 1 and 2147483647')
134
+ }
153
135
  // 明确放弃边界的唯一开关:策略服务缺席时默认拒绝启动,只有这里能改主意。
154
136
  const allowUnconfined = config?.allowUnconfined === true
155
137
 
156
- // 沙箱服务每次启动会话时再取:插件 apply 可能早于沙箱服务挂载,
138
+ // 每次调用都重新取沙箱服务;apply 可能早于服务挂载,后端也可能热替换。
157
139
  // 而沙箱缺席时插件仍需能加载(启动会话时给出明确错误即可)。
158
140
  // 入参是 Agent(不是 ToolExecution):sandboxPolicy.resolve 需要 session 才能读到
159
141
  // 会话的 sandbox/mode 覆盖与 header.cwd。传错形状会静默退回部署默认值
@@ -165,189 +147,177 @@ export function apply(ctx, config) {
165
147
  return sandboxPolicy.resolve(session === undefined ? {} : { session })
166
148
  }
167
149
 
168
- /** sessionId -> Promise<PwshSession>。用 Promise 占位,避免并发首调用各起一个进程。 */
169
- const sessions = new Map()
150
+ installNativePwshGuard(ctx, config?.enforcePwshRun, resolvePolicy)
151
+ // 页面复用 settings 服务的持久化与 revision 校验,不创建额外的配置文件。
152
+ ctx.inject?.(['settings'], (child) => {
153
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber), 'pwsh-run.settings-page')
154
+ })
170
155
 
171
- const sessionKey = (exec) => {
172
- const id = exec?.agent?.session?.id
173
- return typeof id === 'string' && id.length > 0 ? id : 'default'
174
- }
156
+ // 槽位在第一次 await 前登记;同 ID 的创建、边界切换与执行共用队列。
157
+ const sessions = new Map()
158
+ let stopped = false
159
+ const fingerprint = (value) => JSON.stringify(value, (_key, item) => {
160
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
161
+ return Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]]))
162
+ }
163
+ return item
164
+ })
175
165
 
176
- const evict = async (key, session) => {
177
- sessions.delete(key)
178
- if (session !== undefined && session !== null) await session.dispose()
166
+ const evict = async (slot, session, reason) => {
167
+ if (slot.session === session) slot.session = null
168
+ if (session) await session.dispose(reason)
179
169
  }
180
170
 
181
- /** 取一个活着的会话,必要时新建;超出上限淘汰最久未用的。 */
182
- const acquire = async (key, workdir, agent, signal) => {
183
- const existing = sessions.get(key)
184
- if (existing !== undefined) {
185
- let session = null
186
- try {
187
- session = await existing
188
- } catch {
189
- sessions.delete(key)
190
- }
191
- if (session !== null && !session.dead) {
192
- session.lastUsed = Date.now()
193
- return session
194
- }
195
- if (session !== null) await session.dispose()
196
- sessions.delete(key)
171
+ const acquire = async (slot, workdir, agent, signal) => {
172
+ if (signal?.aborted) throw new Error('pwsh_run cancelled before execution')
173
+ const cwd = await resolveCwd(ctx, agent, signal)
174
+ const policy = resolvePolicy(agent) ?? null
175
+ if (signal?.aborted) throw new Error('pwsh_run cancelled before execution')
176
+ if (stopped) throw new Error('pwsh_run plugin was stopped')
177
+ const sandbox = ctx.get('sandbox')
178
+ const identity = fingerprint({ policy, cwd })
179
+ if (slot.session && (slot.identity !== identity || slot.sandbox !== sandbox || slot.session.dead)) {
180
+ await evict(slot, slot.session, 'pwsh session boundary changed')
197
181
  }
182
+ if (policy === null && !allowUnconfined) {
183
+ throw new Error('pwsh_run: ctx.sandboxPolicy is absent, so the workspace boundary cannot be resolved. '
184
+ + 'Mount @deepseek-ai/dsh-sandbox-policy, or set config.allowUnconfined: true to accept an unconfined session.')
185
+ }
186
+ if (policy?.mode === 'read-only') {
187
+ throw new Error('pwsh_run does not support read-only mode: a persistent pwsh session needs FullLanguage. Use the native pwsh tool instead.')
188
+ }
189
+ if (policy !== null && !['workspace-write', 'danger-full-access'].includes(policy.mode)) {
190
+ throw new Error('pwsh_run: unsupported sandbox mode ' + policy.mode)
191
+ }
192
+ if (slot.session) return slot.session
198
193
 
199
- const cwd = workdir ?? await resolveCwd(ctx, agent, signal)
200
- const pending = (async () => {
201
- const executable = await ctx.subprocess.resolveExecutable(pwshPath, undefined, signal)
202
- // 常驻进程只在启动时能 confine 一次——但 ACL 受限令牌是**进程级**的,
203
- // 之后该进程所有读写都受同一 workspace 边界约束,与原生 pwsh 工具等价(实测)。
204
- const policy = resolvePolicy(agent)
205
- const boot = bootArgv(SERVER_SOURCE)
206
- const plain = [executable].concat(boot)
207
- // fail-closed:解析不出边界就拒绝启动,而不是悄悄跑一个不受约束的会话。
208
- if (policy === null && allowUnconfined !== true) {
209
- throw new Error('pwsh_run: ctx.sandboxPolicy is absent, so the workspace boundary cannot be resolved. ' +
210
- 'Mount @deepseek-ai/dsh-sandbox-policy, or set config.allowUnconfined: true to accept an unconfined session ' +
211
- '(file writes would then NOT be restricted to the workspace).')
212
- }
213
- if (policy === null || policy.mode === 'danger-full-access') {
214
- return await startSession(ctx.subprocess, { executable, cwd, serverSource: SERVER_SOURCE, label: key, maxOutputBytes })
215
- }
216
- // read-only 下 pwsh 是 ConstrainedLanguage,连 runspace 都建不起来(实测
217
- // [Text.UTF8Encoding]::new 即报 "Cannot create type"),常驻会话没法启动。
218
- if (policy.mode === 'read-only') {
219
- throw new Error('pwsh_run does not support read-only mode: a persistent pwsh session needs FullLanguage ' +
220
- '(a read-only sandbox runs pwsh in ConstrainedLanguage, where creating a runspace fails). Use the native pwsh tool instead.')
221
- }
222
- const sandbox = ctx.get('sandbox')
223
- if (sandbox === undefined) throw new Error('pwsh_run: ctx.sandbox is absent, so a persistent session cannot be confined. Mount @deepseek-ai/dsh-sandbox-local, or use the native pwsh tool.')
224
- let confined
225
- try {
226
- confined = await sandbox.confine(plain, policy, signal)
227
- } catch (error) {
228
- throw new Error(describeConfineFailure(error, policy.workspaceRoot))
229
- }
230
- return await startSession(ctx.subprocess, { executable, argv: confined.argv, cwd, serverSource: SERVER_SOURCE, label: key, maxOutputBytes })
231
- })()
232
- pending.catch(() => sessions.delete(key))
233
- sessions.set(key, pending)
234
-
235
- if (sessions.size > maxSessions) {
236
- const others = [...sessions.entries()].filter(([victim]) => victim !== key)
237
- const ages = []
238
- for (const entry of others) {
239
- let lastUsed = 0
194
+ const live = [...sessions.values()].filter((item) => item.session || item.starting)
195
+ let retiring
196
+ if (live.length >= maxSessions) {
197
+ const victim = live.filter((item) => item !== slot && item.users === 0 && !item.starting)
198
+ .sort((a, b) => a.session.lastUsed - b.session.lastUsed)[0]
199
+ if (!victim) throw new Error('pwsh_run: all ' + maxSessions + ' sessions are busy; retry when a call finishes')
200
+ retiring = evict(victim, victim.session, 'pwsh session evicted while idle')
201
+ if (sessions.get(victim.key) === victim) sessions.delete(victim.key)
202
+ }
203
+ // 在等待旧进程退出前占住容量,另一 ID 不能同时认领最后一个位置。
204
+ slot.starting = true
205
+ slot.startup = new AbortController()
206
+ const startupSignal = signal ? AbortSignal.any([signal, slot.startup.signal]) : slot.startup.signal
207
+ try {
208
+ await retiring
209
+ if (stopped || startupSignal.aborted) throw new Error('pwsh_run session startup cancelled')
210
+ const executable = await ctx.subprocess.resolveExecutable(pwshPath, undefined, startupSignal)
211
+ let argv
212
+ if (policy !== null && policy.mode !== 'danger-full-access') {
213
+ if (!sandbox) throw new Error('pwsh_run: ctx.sandbox is absent, so a persistent session cannot be confined. Mount @deepseek-ai/dsh-sandbox-local, or use the native pwsh tool.')
240
214
  try {
241
- lastUsed = (await entry[1]).lastUsed
242
- } catch {
243
- lastUsed = 0
215
+ const confined = await sandbox.confine([executable, ...bootArgv(SERVER_SOURCE)], policy, startupSignal)
216
+ argv = confined?.argv
217
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((item) => typeof item !== 'string')) {
218
+ throw new Error('sandbox.confine returned no valid confined argv')
219
+ }
244
220
  }
245
- ages.push([entry[0], entry[1], lastUsed])
221
+ catch (error) { throw new Error(describeConfineFailure(error, policy.workspaceRoot)) }
246
222
  }
247
- ages.sort((a, b) => a[2] - b[2])
248
- for (const [victimKey, victim] of ages.slice(0, Math.max(0, sessions.size - maxSessions))) {
249
- sessions.delete(victimKey)
250
- Promise.resolve(victim).then((session) => session.dispose(), () => undefined)
223
+ const session = await startSession(ctx.subprocess, { executable, argv, cwd: workdir ?? cwd,
224
+ serverSource: SERVER_SOURCE, label: slot.key, maxOutputBytes, signal: startupSignal })
225
+ if (stopped || startupSignal.aborted) {
226
+ await session.dispose('pwsh session startup cancelled')
227
+ throw new Error('pwsh_run session startup cancelled')
251
228
  }
252
- }
253
- return await pending
229
+ slot.session = session
230
+ slot.identity = identity
231
+ slot.sandbox = sandbox
232
+ return session
233
+ } finally { slot.starting = false; slot.startup = null }
254
234
  }
255
235
 
256
- // 空闲会话回收,避免常驻进程无限累积。
257
236
  const reaper = setInterval(() => {
258
237
  const now = Date.now()
259
- for (const [key, pending] of [...sessions.entries()]) {
260
- Promise.resolve(pending).then(async (session) => {
261
- if (now - session.lastUsed > idleMs) await evict(key, session)
262
- }, () => sessions.delete(key))
238
+ for (const [key, slot] of sessions) {
239
+ if (slot.users === 0 && !slot.starting && slot.session && now - slot.session.lastUsed > idleMs) {
240
+ const retiring = evict(slot, slot.session, 'pwsh session expired while idle')
241
+ if (sessions.get(key) === slot) sessions.delete(key)
242
+ void retiring
243
+ }
263
244
  }
264
245
  }, Math.min(idleMs, 60000))
265
- if (typeof reaper.unref === 'function') reaper.unref()
246
+ reaper.unref?.()
266
247
 
267
- /** 插件卸载 / 热重载时清干净:停回收器 + 杀掉所有会话。 */
268
248
  ctx.effect(() => () => {
249
+ stopped = true
269
250
  clearInterval(reaper)
270
- for (const pending of sessions.values()) Promise.resolve(pending).then((session) => session.dispose(), () => undefined)
251
+ for (const slot of sessions.values()) {
252
+ slot.startup?.abort()
253
+ if (slot.session) void slot.session.dispose('pwsh_run plugin stopped')
254
+ }
271
255
  sessions.clear()
272
256
  }, 'pwsh-run.sessions')
273
257
 
274
-
275
- /** 一条调用的实体。 */
276
- const runScript = async (args, exec) => {
277
- const requested = Number.isFinite(args?.timeoutMs) && args.timeoutMs > 0 ? Math.trunc(args.timeoutMs) : defaultTimeoutMs
278
- const key = sessionKey(exec)
279
- const workdir = typeof args?.workdir === 'string' && args.workdir.length > 0 ? args.workdir : undefined
280
-
281
- // workdir 通过 Set-Location 生效(会话有状态,所以下一次调用会继承)。
282
- const script = workdir === undefined
283
- ? args.script
284
- : 'Set-Location -LiteralPath ' + psLiteral(workdir) + " -ErrorAction Stop\n" + args.script
285
-
258
+ const runScript = async (args, exec, slot) => {
259
+ const requested = args.timeoutMs ?? defaultTimeoutMs
260
+ const workdir = typeof args.workdir === 'string' && args.workdir.length > 0 ? args.workdir : undefined
261
+ // 工作目录用 API 设置,避免在正文前拼语句破坏 using / param / #requires。
286
262
  let session
287
- try {
288
- session = await acquire(key, workdir, exec?.agent, exec?.signal)
289
- } catch (error) {
290
- // acquire 里已经写好人话的错误(沙箱缺席、read-only、confine 失败)不再套第二层前缀。
291
- // 用 \b 而不是 'pwsh_run ':几条自带文案是 'pwsh_run: ...' 冒号收尾,带空格的匹配会漏掉它们。
263
+ try { session = await acquire(slot, workdir, exec?.agent, exec?.signal) }
264
+ catch (error) {
292
265
  const message = String(error?.message ?? error)
293
266
  throw new Error(/^pwsh_run\b/.test(message) ? message : 'pwsh_run could not start a session: ' + message)
294
267
  }
295
-
296
- const local = withTimeout(exec?.signal, requested + RESPONSE_GRACE_MS)
297
- let expired = false
298
- const onAbort = () => { expired = true }
299
- local.addEventListener('abort', onAbort, { once: true })
300
-
301
- let response = null
302
- try {
303
- response = await Promise.race([
304
- session.run(script, requested),
305
- new Promise((resolve) => {
306
- local.addEventListener('abort', () => resolve(null), { once: true })
307
- }),
308
- ])
309
- } catch (error) {
310
- // 会话中途死亡:清掉,下一次调用自动重建。
311
- await evict(key, session)
312
- const message = String(error)
313
- const note = session.dead ? '[' + message + '; a new session starts on the next call]' : '[' + message + ']'
314
- return { text: note }
315
- } finally {
316
- local.removeEventListener('abort', onAbort)
268
+ let response
269
+ try { response = await session.run(args.script, requested, { signal: exec?.signal, workdir }) }
270
+ catch (error) {
271
+ await evict(slot, session, 'pwsh session execution failed')
272
+ return { text: exec?.signal?.aborted
273
+ ? '[cancelled; the session was terminated and is rebuilt on the next call]'
274
+ : '[' + String(error) + '; a new session starts on the next call]' }
317
275
  }
318
-
319
276
  const notes = []
320
- if (response?.truncated === true) notes.push('[output truncated at ' + String(maxOutputBytes) + ' bytes; redirect to a file if you need it all]')
321
- if (response === null || expired) {
322
- // 服务端没能在预算内回话:整个进程杀掉重建,不让一条调用拖垮后续。
323
- await evict(key, session)
324
- notes.push(exec?.signal?.aborted === true
325
- ? '[cancelled; the session was terminated and is rebuilt on the next call]'
326
- : '[no response from the pwsh session within ' + String(Math.round(requested / 1000)) + 's; the session was terminated and is rebuilt on the next call]')
327
- } else if (session.dead) {
328
- await evict(key, session)
329
- notes.push('[' + String(session.deathReason) + '; a new session starts on the next call]')
330
- } else if (response.timedOut) {
331
- // 服务端 Stop() 能打断脚本,但脚本内 Start-Process 起的子进程不会随之回收
332
- // (实测子进程仍存活)。杀掉整个会话才能靠 Job Object 连带清干净。
333
- await evict(key, session)
334
- notes.push('[timed out after ' + String(Math.round(requested / 1000)) + 's; partial output above; the session was terminated and is rebuilt on the next call]')
277
+ if (response.truncated) notes.push('[output truncated at ' + maxOutputBytes + ' output bytes plus a separate 65536-byte error budget; redirect to a file if you need it all]')
278
+ if (response.cancelled || response.noResponse || response.timedOut || session.dead) {
279
+ await evict(slot, session)
280
+ if (response.cancelled) notes.push('[cancelled; the session was terminated and is rebuilt on the next call]')
281
+ else if (response.timedOut) notes.push('[timed out after ' + requested + 'ms; partial output above; the session was terminated and is rebuilt on the next call]')
282
+ else notes.push('[no response from the pwsh session within ' + requested + 'ms; the session was terminated and is rebuilt on the next call]')
335
283
  }
336
-
337
- const body = []
338
- for (const line of response?.out ?? []) body.push(line)
339
- for (const line of response?.warn ?? []) body.push('WARNING: ' + line)
340
- for (const line of response?.verbose ?? []) body.push('VERBOSE: ' + line)
341
- if (response?.sentinel === 'threw') body.push('[script threw]')
342
- const text = [body.join('\n')]
343
- .concat(response?.err ?? [])
344
- .concat(response?.exceptions ?? [])
345
- .concat(response?.fatal ?? [])
346
- .join('\n')
284
+ const body = [...response.out,
285
+ ...response.warn.map((line) => 'WARNING: ' + line),
286
+ ...response.verbose.map((line) => 'VERBOSE: ' + line),
287
+ ...response.information.map((line) => 'INFORMATION: ' + line),
288
+ ...response.debug.map((line) => 'DEBUG: ' + line)]
289
+ if (response.sentinel === 'threw') body.push('[script threw]')
290
+ const text = [...body, ...response.err, ...response.exceptions, ...response.fatal].join('\n')
347
291
  const denial = describeExecDenial(text)
348
292
  if (denial !== null) notes.push(denial)
349
- const all = [text].concat(notes).filter((part) => part.length > 0).join('\n')
350
- return { text: all.length > 0 ? all : '(no output)' }
293
+ return { text: [text, ...notes].filter(Boolean).join('\n') || '(no output)' }
294
+ }
295
+
296
+ const enqueue = (args, exec) => {
297
+ validateTimeout(args?.timeoutMs ?? defaultTimeoutMs)
298
+ if (typeof args?.script !== 'string') throw new Error('pwsh_run script must be a string')
299
+ if (exec?.signal?.aborted) throw new Error('pwsh_run cancelled before execution')
300
+ const key = exec?.agent?.session?.id
301
+ if (typeof key !== 'string' || !key) throw new Error('pwsh_run requires a session ID to isolate persistent state')
302
+ let slot = sessions.get(key)
303
+ if (!slot) {
304
+ slot = { key, tail: Promise.resolve(), session: null, users: 0, starting: false }
305
+ sessions.set(key, slot)
306
+ }
307
+ slot.users++
308
+ let executing = false
309
+ const operation = slot.tail.then(() => {
310
+ executing = true
311
+ if (exec?.signal?.aborted) throw new Error('pwsh_run cancelled before execution')
312
+ if (stopped) throw new Error('pwsh_run plugin was stopped')
313
+ return runScript(args, exec, slot)
314
+ }).finally(() => {
315
+ slot.users--
316
+ if (slot.session) slot.session.lastUsed = Date.now()
317
+ if (!slot.session && slot.users === 0 && sessions.get(key) === slot) sessions.delete(key)
318
+ })
319
+ slot.tail = operation.then(() => undefined, () => undefined)
320
+ return abortable(operation, exec?.signal, () => !executing)
351
321
  }
352
322
 
353
323
  // 工具注册也要挂到 effect 上:live patchReload 会重新 apply 本模块,
@@ -358,9 +328,9 @@ export function apply(ctx, config) {
358
328
  'Run a PowerShell script in a **persistent** pwsh 7 session.',
359
329
  '* Send the script body verbatim: do NOT escape $, do NOT wrap it in pwsh -c, do NOT add shell quotes. There is no shell in between — the script goes straight to pwsh over stdin.',
360
330
  '* Prefer this over the pwsh tool when the script contains $variables, here-strings (@\'...\'@ / @"..."@), backticks, quotes, non-ASCII text, or is longer than a few thousand characters. The pwsh tool is capped by the Windows command line (~32K) and every call is a fresh process.',
361
- '* State persists across calls in the same session: variables, current directory, $env: entries and imported modules. Do not assume a clean slate; reset explicitly when you need to.',
362
- '* Output ordering is preserved and streams are labelled (stdout / error / warning / verbose).',
363
- '* Each call times out after ' + String(Math.round(defaultTimeoutMs / 1000)) + 's by default (override with timeoutMs). On timeout the whole session process is terminated and rebuilt — persisted state is lost.',
331
+ '* State persists while the session ID, sandbox policy and workspace stay the same: variables, current directory, $env: entries and imported modules. Do not assume a clean slate; reset explicitly when you need to.',
332
+ '* Output uses PowerShell formatting; streams are labelled and grouped by type, so inter-stream ordering is not preserved.',
333
+ '* Execution time excludes queue waiting. Each call times out after ' + String(Math.round(defaultTimeoutMs / 1000)) + 's by default (override with timeoutMs). On timeout the whole session process is terminated and rebuilt — persisted state is lost.',
364
334
  '* exit N inside the script only ends that call; its exit code is NOT observable. Use $LASTEXITCODE or Write-Output instead.',
365
335
  '* Sandbox: the persistent process is confined once at launch, and because the Windows ACL restricted token applies to the whole process, every later read/write in this session obeys the same workspace boundary as the native pwsh tool. In read-only mode this tool refuses to start (a persistent session needs FullLanguage) — use the native pwsh tool there.',
366
336
  '* Not for interactive programs or anything that needs a TTY.',
@@ -376,7 +346,7 @@ export function apply(ctx, config) {
376
346
  render: (_args, value) => [{ type: 'text', text: value.text }],
377
347
  },
378
348
  async execute(args, exec) {
379
- return await runScript(args, exec)
349
+ return await enqueue(args, exec)
380
350
  },
381
351
  }), 'pwsh-run.tool')
382
- }
352
+ }
@@ -0,0 +1,43 @@
1
+ const MODE_RANK = new Map([
2
+ ['read-only', 0], ['workspace-write', 1], ['danger-full-access', 2],
3
+ ])
4
+
5
+ const DENIAL = 'Ordinary native pwsh calls are disabled by config.enforcePwshRun. '
6
+ + 'Use pwsh_run instead: pass the PowerShell command as script, and keep workdir and timeoutMs when supplied. '
7
+ + 'The native command was not executed.'
8
+
9
+ /** 守卫在宿主执行前策略之后检查;不用可被后续监听器改回 allow 的瀑布钩子。 */
10
+ export function installNativePwshGuard(ctx, enabled, resolvePolicy) {
11
+ const live = enabled !== null && typeof enabled === 'object' && typeof enabled.get === 'function'
12
+ const readEnabled = () => {
13
+ const value = live ? enabled.get() : enabled
14
+ if (value !== undefined && typeof value !== 'boolean') {
15
+ throw new Error('pwsh_run enforcePwshRun must be a boolean')
16
+ }
17
+ return value === true
18
+ }
19
+ const initial = readEnabled()
20
+ if (!live && !initial) return
21
+ if (typeof ctx.tools.guard !== 'function') {
22
+ throw new Error('pwsh_run enforcePwshRun requires a DSH host with tools.guard(); '
23
+ + 'upgrade DSH to use this routing configuration. No routing guard was installed.')
24
+ }
25
+
26
+ ctx.effect(() => ctx.tools.guard((exec) => {
27
+ // Loader 原地更新 volatile 引用;读取它不会销毁正在使用的常驻进程。
28
+ if (exec.name !== 'pwsh' || !readEnabled()) return undefined
29
+ // 后台 job 的句柄与逐次审批由原生工具持有,常驻会话不能代接。
30
+ const args = exec.arguments
31
+ if (args?.run_in_background === true) return undefined
32
+ const mode = resolvePolicy(exec.agent)?.mode
33
+ if (mode === 'read-only') return undefined
34
+ const requestedRank = MODE_RANK.get(args?.sandbox_permissions)
35
+ const currentRank = MODE_RANK.get(mode)
36
+ if (requestedRank !== undefined && currentRank !== undefined && requestedRank > currentRank
37
+ && typeof args?.justification === 'string' && args.justification.trim().length > 0) {
38
+ // 只放行真实的提权请求;是否批准仍由原生工具的审批机制决定。
39
+ return undefined
40
+ }
41
+ return DENIAL
42
+ }), 'pwsh-run.native-guard')
43
+ }