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/index.mjs
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* pwsh-run —— 常驻 PowerShell 会话工具(插件行)
|
|
3
3
|
*
|
|
4
4
|
* 为什么需要它
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
21
|
+
* 不是逐命令级;因此每次复用前核对策略、工作区与后端,变化就重建)。
|
|
23
22
|
* read-only 模式下 pwsh 以 ConstrainedLanguage 运行,无法构造 runspace,
|
|
24
23
|
* 因此常驻会话在该模式下不启动,直接报错让模型改用原生 pwsh 工具。
|
|
25
24
|
* ctx.sandboxPolicy 缺席时同样拒绝启动(fail-closed),除非显式 config.allowUnconfined: true。
|
|
@@ -30,7 +29,7 @@
|
|
|
30
29
|
*/
|
|
31
30
|
|
|
32
31
|
import { readFileSync } from 'node:fs'
|
|
33
|
-
import { DEFAULT_MAX_OUTPUT_BYTES, bootArgv, startSession } from './session.mjs'
|
|
32
|
+
import { DEFAULT_MAX_OUTPUT_BYTES, MAX_TIMEOUT_MS, bootArgv, startSession, validateTimeout, abortable } from './session.mjs'
|
|
34
33
|
|
|
35
34
|
/** 加载器诊断里显示的插件名。 */
|
|
36
35
|
export const name = 'pwsh-run'
|
|
@@ -41,9 +40,6 @@ export const inject = ['subprocess', 'tools']
|
|
|
41
40
|
const DEFAULT_TIMEOUT_MS = 120000
|
|
42
41
|
const DEFAULT_IDLE_MS = 30 * 60 * 1000
|
|
43
42
|
const DEFAULT_MAX_SESSIONS = 4
|
|
44
|
-
/** 调用方超时之外多给的余量:服务端被打断后还要把尾部输出吐完、写结束标记。 */
|
|
45
|
-
const RESPONSE_GRACE_MS = 5000
|
|
46
|
-
|
|
47
43
|
/** 服务端脚本:随插件分发,启动时经 -EncodedCommand 送入,不落地临时文件。 */
|
|
48
44
|
const SERVER_SOURCE = readFileSync(new URL('./session-server.ps1', import.meta.url), 'utf8')
|
|
49
45
|
|
|
@@ -59,7 +55,9 @@ const parameters = {
|
|
|
59
55
|
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
56
|
},
|
|
61
57
|
timeoutMs: {
|
|
62
|
-
type: '
|
|
58
|
+
type: 'integer',
|
|
59
|
+
minimum: 1,
|
|
60
|
+
maximum: MAX_TIMEOUT_MS,
|
|
63
61
|
description: 'Timeout for this call in milliseconds; defaults to 120000. On timeout the session process is terminated and rebuilt.',
|
|
64
62
|
},
|
|
65
63
|
},
|
|
@@ -69,32 +67,17 @@ const parameters = {
|
|
|
69
67
|
|
|
70
68
|
/**
|
|
71
69
|
* 解析子进程的启动目录。
|
|
72
|
-
* 与
|
|
70
|
+
* 与 wsl/custom-bash.mjs 同款:workingDirectory 服务优先(会话中途切目录后
|
|
73
71
|
* header.cwd 不会更新),服务缺失时退回 header.cwd,再退回进程 cwd。
|
|
74
72
|
*/
|
|
75
73
|
async function resolveCwd(ctx, agent, signal) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (service !== undefined && agent !== undefined) return await service.ensure(agent, signal)
|
|
79
|
-
} catch {
|
|
80
|
-
// 服务异常时退回旧路径,不让一次目录查询拖垮工具调用。
|
|
81
|
-
}
|
|
74
|
+
const service = ctx.get('workingDirectory')
|
|
75
|
+
if (service !== undefined && agent !== undefined) return await service.ensure(agent, signal)
|
|
82
76
|
const header = agent?.session?.header
|
|
83
77
|
if (typeof header?.cwd === 'string' && header.cwd.length > 0) return header.cwd
|
|
84
78
|
return process.cwd()
|
|
85
79
|
}
|
|
86
80
|
|
|
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
81
|
/**
|
|
99
82
|
* confine 失败时把原始 Win32 错误翻译成能照着做的说明。
|
|
100
83
|
* 最常见的一种:工作区根的 DACL 没给 WRITE_DAC / WRITE_OWNER。沙箱要在目录上写 DACL 与
|
|
@@ -138,22 +121,19 @@ export function describeExecDenial(text) {
|
|
|
138
121
|
+ ' detaches the child std handles, so session corruption is prevented — the lost output is not.]'
|
|
139
122
|
}
|
|
140
123
|
|
|
141
|
-
/** 合并调用方取消信号与本地超时信号。 */
|
|
142
|
-
function withTimeout(signal, timeoutMs) {
|
|
143
|
-
const timeout = AbortSignal.timeout(timeoutMs)
|
|
144
|
-
return signal === undefined ? timeout : AbortSignal.any([signal, timeout])
|
|
145
|
-
}
|
|
146
|
-
|
|
147
124
|
export function apply(ctx, config) {
|
|
148
125
|
const pwshPath = typeof config?.pwshPath === 'string' && config.pwshPath.length > 0 ? config.pwshPath : 'pwsh'
|
|
149
|
-
const defaultTimeoutMs =
|
|
126
|
+
const defaultTimeoutMs = config?.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : validateTimeout(config.timeoutMs)
|
|
150
127
|
const idleMs = Number.isSafeInteger(config?.idleMs) && config.idleMs > 0 ? config.idleMs : DEFAULT_IDLE_MS
|
|
151
128
|
const maxSessions = Number.isSafeInteger(config?.maxSessions) && config.maxSessions > 0 ? config.maxSessions : DEFAULT_MAX_SESSIONS
|
|
152
|
-
const maxOutputBytes =
|
|
129
|
+
const maxOutputBytes = config?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES
|
|
130
|
+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 2147483647) {
|
|
131
|
+
throw new Error('pwsh_run maxOutputBytes must be an integer between 1 and 2147483647')
|
|
132
|
+
}
|
|
153
133
|
// 明确放弃边界的唯一开关:策略服务缺席时默认拒绝启动,只有这里能改主意。
|
|
154
134
|
const allowUnconfined = config?.allowUnconfined === true
|
|
155
135
|
|
|
156
|
-
//
|
|
136
|
+
// 每次调用都重新取沙箱服务;apply 可能早于服务挂载,后端也可能热替换。
|
|
157
137
|
// 而沙箱缺席时插件仍需能加载(启动会话时给出明确错误即可)。
|
|
158
138
|
// 入参是 Agent(不是 ToolExecution):sandboxPolicy.resolve 需要 session 才能读到
|
|
159
139
|
// 会话的 sandbox/mode 覆盖与 header.cwd。传错形状会静默退回部署默认值
|
|
@@ -165,189 +145,171 @@ export function apply(ctx, config) {
|
|
|
165
145
|
return sandboxPolicy.resolve(session === undefined ? {} : { session })
|
|
166
146
|
}
|
|
167
147
|
|
|
168
|
-
|
|
148
|
+
// 槽位在第一次 await 前登记;同 ID 的创建、边界切换与执行共用队列。
|
|
169
149
|
const sessions = new Map()
|
|
150
|
+
let stopped = false
|
|
151
|
+
const fingerprint = (value) => JSON.stringify(value, (_key, item) => {
|
|
152
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
153
|
+
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]]))
|
|
154
|
+
}
|
|
155
|
+
return item
|
|
156
|
+
})
|
|
170
157
|
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const evict = async (key, session) => {
|
|
177
|
-
sessions.delete(key)
|
|
178
|
-
if (session !== undefined && session !== null) await session.dispose()
|
|
158
|
+
const evict = async (slot, session, reason) => {
|
|
159
|
+
if (slot.session === session) slot.session = null
|
|
160
|
+
if (session) await session.dispose(reason)
|
|
179
161
|
}
|
|
180
162
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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)
|
|
163
|
+
const acquire = async (slot, workdir, agent, signal) => {
|
|
164
|
+
if (signal?.aborted) throw new Error('pwsh_run cancelled before execution')
|
|
165
|
+
const cwd = await resolveCwd(ctx, agent, signal)
|
|
166
|
+
const policy = resolvePolicy(agent) ?? null
|
|
167
|
+
if (signal?.aborted) throw new Error('pwsh_run cancelled before execution')
|
|
168
|
+
if (stopped) throw new Error('pwsh_run plugin was stopped')
|
|
169
|
+
const sandbox = ctx.get('sandbox')
|
|
170
|
+
const identity = fingerprint({ policy, cwd })
|
|
171
|
+
if (slot.session && (slot.identity !== identity || slot.sandbox !== sandbox || slot.session.dead)) {
|
|
172
|
+
await evict(slot, slot.session, 'pwsh session boundary changed')
|
|
197
173
|
}
|
|
174
|
+
if (policy === null && !allowUnconfined) {
|
|
175
|
+
throw new Error('pwsh_run: ctx.sandboxPolicy is absent, so the workspace boundary cannot be resolved. '
|
|
176
|
+
+ 'Mount @deepseek-ai/dsh-sandbox-policy, or set config.allowUnconfined: true to accept an unconfined session.')
|
|
177
|
+
}
|
|
178
|
+
if (policy?.mode === 'read-only') {
|
|
179
|
+
throw new Error('pwsh_run does not support read-only mode: a persistent pwsh session needs FullLanguage. Use the native pwsh tool instead.')
|
|
180
|
+
}
|
|
181
|
+
if (policy !== null && !['workspace-write', 'danger-full-access'].includes(policy.mode)) {
|
|
182
|
+
throw new Error('pwsh_run: unsupported sandbox mode ' + policy.mode)
|
|
183
|
+
}
|
|
184
|
+
if (slot.session) return slot.session
|
|
198
185
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
186
|
+
const live = [...sessions.values()].filter((item) => item.session || item.starting)
|
|
187
|
+
let retiring
|
|
188
|
+
if (live.length >= maxSessions) {
|
|
189
|
+
const victim = live.filter((item) => item !== slot && item.users === 0 && !item.starting)
|
|
190
|
+
.sort((a, b) => a.session.lastUsed - b.session.lastUsed)[0]
|
|
191
|
+
if (!victim) throw new Error('pwsh_run: all ' + maxSessions + ' sessions are busy; retry when a call finishes')
|
|
192
|
+
retiring = evict(victim, victim.session, 'pwsh session evicted while idle')
|
|
193
|
+
if (sessions.get(victim.key) === victim) sessions.delete(victim.key)
|
|
194
|
+
}
|
|
195
|
+
// 在等待旧进程退出前占住容量,另一 ID 不能同时认领最后一个位置。
|
|
196
|
+
slot.starting = true
|
|
197
|
+
slot.startup = new AbortController()
|
|
198
|
+
const startupSignal = signal ? AbortSignal.any([signal, slot.startup.signal]) : slot.startup.signal
|
|
199
|
+
try {
|
|
200
|
+
await retiring
|
|
201
|
+
if (stopped || startupSignal.aborted) throw new Error('pwsh_run session startup cancelled')
|
|
202
|
+
const executable = await ctx.subprocess.resolveExecutable(pwshPath, undefined, startupSignal)
|
|
203
|
+
let argv
|
|
204
|
+
if (policy !== null && policy.mode !== 'danger-full-access') {
|
|
205
|
+
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
206
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
207
|
+
const confined = await sandbox.confine([executable, ...bootArgv(SERVER_SOURCE)], policy, startupSignal)
|
|
208
|
+
argv = confined?.argv
|
|
209
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.some((item) => typeof item !== 'string')) {
|
|
210
|
+
throw new Error('sandbox.confine returned no valid confined argv')
|
|
211
|
+
}
|
|
244
212
|
}
|
|
245
|
-
|
|
213
|
+
catch (error) { throw new Error(describeConfineFailure(error, policy.workspaceRoot)) }
|
|
246
214
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
215
|
+
const session = await startSession(ctx.subprocess, { executable, argv, cwd: workdir ?? cwd,
|
|
216
|
+
serverSource: SERVER_SOURCE, label: slot.key, maxOutputBytes, signal: startupSignal })
|
|
217
|
+
if (stopped || startupSignal.aborted) {
|
|
218
|
+
await session.dispose('pwsh session startup cancelled')
|
|
219
|
+
throw new Error('pwsh_run session startup cancelled')
|
|
251
220
|
}
|
|
252
|
-
|
|
253
|
-
|
|
221
|
+
slot.session = session
|
|
222
|
+
slot.identity = identity
|
|
223
|
+
slot.sandbox = sandbox
|
|
224
|
+
return session
|
|
225
|
+
} finally { slot.starting = false; slot.startup = null }
|
|
254
226
|
}
|
|
255
227
|
|
|
256
|
-
// 空闲会话回收,避免常驻进程无限累积。
|
|
257
228
|
const reaper = setInterval(() => {
|
|
258
229
|
const now = Date.now()
|
|
259
|
-
for (const [key,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
230
|
+
for (const [key, slot] of sessions) {
|
|
231
|
+
if (slot.users === 0 && !slot.starting && slot.session && now - slot.session.lastUsed > idleMs) {
|
|
232
|
+
const retiring = evict(slot, slot.session, 'pwsh session expired while idle')
|
|
233
|
+
if (sessions.get(key) === slot) sessions.delete(key)
|
|
234
|
+
void retiring
|
|
235
|
+
}
|
|
263
236
|
}
|
|
264
237
|
}, Math.min(idleMs, 60000))
|
|
265
|
-
|
|
238
|
+
reaper.unref?.()
|
|
266
239
|
|
|
267
|
-
/** 插件卸载 / 热重载时清干净:停回收器 + 杀掉所有会话。 */
|
|
268
240
|
ctx.effect(() => () => {
|
|
241
|
+
stopped = true
|
|
269
242
|
clearInterval(reaper)
|
|
270
|
-
for (const
|
|
243
|
+
for (const slot of sessions.values()) {
|
|
244
|
+
slot.startup?.abort()
|
|
245
|
+
if (slot.session) void slot.session.dispose('pwsh_run plugin stopped')
|
|
246
|
+
}
|
|
271
247
|
sessions.clear()
|
|
272
248
|
}, 'pwsh-run.sessions')
|
|
273
249
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
250
|
+
const runScript = async (args, exec, slot) => {
|
|
251
|
+
const requested = args.timeoutMs ?? defaultTimeoutMs
|
|
252
|
+
const workdir = typeof args.workdir === 'string' && args.workdir.length > 0 ? args.workdir : undefined
|
|
253
|
+
// 工作目录用 API 设置,避免在正文前拼语句破坏 using / param / #requires。
|
|
286
254
|
let session
|
|
287
|
-
try {
|
|
288
|
-
|
|
289
|
-
} catch (error) {
|
|
290
|
-
// acquire 里已经写好人话的错误(沙箱缺席、read-only、confine 失败)不再套第二层前缀。
|
|
291
|
-
// 用 \b 而不是 'pwsh_run ':几条自带文案是 'pwsh_run: ...' 冒号收尾,带空格的匹配会漏掉它们。
|
|
255
|
+
try { session = await acquire(slot, workdir, exec?.agent, exec?.signal) }
|
|
256
|
+
catch (error) {
|
|
292
257
|
const message = String(error?.message ?? error)
|
|
293
258
|
throw new Error(/^pwsh_run\b/.test(message) ? message : 'pwsh_run could not start a session: ' + message)
|
|
294
259
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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)
|
|
260
|
+
let response
|
|
261
|
+
try { response = await session.run(args.script, requested, { signal: exec?.signal, workdir }) }
|
|
262
|
+
catch (error) {
|
|
263
|
+
await evict(slot, session, 'pwsh session execution failed')
|
|
264
|
+
return { text: exec?.signal?.aborted
|
|
265
|
+
? '[cancelled; the session was terminated and is rebuilt on the next call]'
|
|
266
|
+
: '[' + String(error) + '; a new session starts on the next call]' }
|
|
317
267
|
}
|
|
318
|
-
|
|
319
268
|
const notes = []
|
|
320
|
-
if (response
|
|
321
|
-
if (response
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
notes.push(
|
|
325
|
-
|
|
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]')
|
|
269
|
+
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]')
|
|
270
|
+
if (response.cancelled || response.noResponse || response.timedOut || session.dead) {
|
|
271
|
+
await evict(slot, session)
|
|
272
|
+
if (response.cancelled) notes.push('[cancelled; the session was terminated and is rebuilt on the next call]')
|
|
273
|
+
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]')
|
|
274
|
+
else notes.push('[no response from the pwsh session within ' + requested + 'ms; the session was terminated and is rebuilt on the next call]')
|
|
335
275
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (response
|
|
342
|
-
const text = [body.join('\n')
|
|
343
|
-
.concat(response?.err ?? [])
|
|
344
|
-
.concat(response?.exceptions ?? [])
|
|
345
|
-
.concat(response?.fatal ?? [])
|
|
346
|
-
.join('\n')
|
|
276
|
+
const body = [...response.out,
|
|
277
|
+
...response.warn.map((line) => 'WARNING: ' + line),
|
|
278
|
+
...response.verbose.map((line) => 'VERBOSE: ' + line),
|
|
279
|
+
...response.information.map((line) => 'INFORMATION: ' + line),
|
|
280
|
+
...response.debug.map((line) => 'DEBUG: ' + line)]
|
|
281
|
+
if (response.sentinel === 'threw') body.push('[script threw]')
|
|
282
|
+
const text = [...body, ...response.err, ...response.exceptions, ...response.fatal].join('\n')
|
|
347
283
|
const denial = describeExecDenial(text)
|
|
348
284
|
if (denial !== null) notes.push(denial)
|
|
349
|
-
|
|
350
|
-
|
|
285
|
+
return { text: [text, ...notes].filter(Boolean).join('\n') || '(no output)' }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const enqueue = (args, exec) => {
|
|
289
|
+
validateTimeout(args?.timeoutMs ?? defaultTimeoutMs)
|
|
290
|
+
if (typeof args?.script !== 'string') throw new Error('pwsh_run script must be a string')
|
|
291
|
+
if (exec?.signal?.aborted) throw new Error('pwsh_run cancelled before execution')
|
|
292
|
+
const key = exec?.agent?.session?.id
|
|
293
|
+
if (typeof key !== 'string' || !key) throw new Error('pwsh_run requires a session ID to isolate persistent state')
|
|
294
|
+
let slot = sessions.get(key)
|
|
295
|
+
if (!slot) {
|
|
296
|
+
slot = { key, tail: Promise.resolve(), session: null, users: 0, starting: false }
|
|
297
|
+
sessions.set(key, slot)
|
|
298
|
+
}
|
|
299
|
+
slot.users++
|
|
300
|
+
let executing = false
|
|
301
|
+
const operation = slot.tail.then(() => {
|
|
302
|
+
executing = true
|
|
303
|
+
if (exec?.signal?.aborted) throw new Error('pwsh_run cancelled before execution')
|
|
304
|
+
if (stopped) throw new Error('pwsh_run plugin was stopped')
|
|
305
|
+
return runScript(args, exec, slot)
|
|
306
|
+
}).finally(() => {
|
|
307
|
+
slot.users--
|
|
308
|
+
if (slot.session) slot.session.lastUsed = Date.now()
|
|
309
|
+
if (!slot.session && slot.users === 0 && sessions.get(key) === slot) sessions.delete(key)
|
|
310
|
+
})
|
|
311
|
+
slot.tail = operation.then(() => undefined, () => undefined)
|
|
312
|
+
return abortable(operation, exec?.signal, () => !executing)
|
|
351
313
|
}
|
|
352
314
|
|
|
353
315
|
// 工具注册也要挂到 effect 上:live patchReload 会重新 apply 本模块,
|
|
@@ -358,9 +320,9 @@ export function apply(ctx, config) {
|
|
|
358
320
|
'Run a PowerShell script in a **persistent** pwsh 7 session.',
|
|
359
321
|
'* 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
322
|
'* 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
|
|
362
|
-
'* Output
|
|
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.',
|
|
323
|
+
'* 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.',
|
|
324
|
+
'* Output uses PowerShell formatting; streams are labelled and grouped by type, so inter-stream ordering is not preserved.',
|
|
325
|
+
'* 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
326
|
'* exit N inside the script only ends that call; its exit code is NOT observable. Use $LASTEXITCODE or Write-Output instead.',
|
|
365
327
|
'* 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
328
|
'* Not for interactive programs or anything that needs a TTY.',
|
|
@@ -376,7 +338,7 @@ export function apply(ctx, config) {
|
|
|
376
338
|
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
377
339
|
},
|
|
378
340
|
async execute(args, exec) {
|
|
379
|
-
return await
|
|
341
|
+
return await enqueue(args, exec)
|
|
380
342
|
},
|
|
381
343
|
}), 'pwsh-run.tool')
|
|
382
|
-
}
|
|
344
|
+
}
|