dsh-pwsh-plugin 0.0.0-stage → 0.2.13

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.
@@ -0,0 +1,580 @@
1
+ /**
2
+ * pwsh-run —— 常驻 PowerShell 会话工具(插件行)
3
+ *
4
+ * 为什么需要它
5
+ * DSH 的 bash 工具把命令包成 /bin/bash -c "<cmd>" 再交给 shell,
6
+ * 所以任何经过 bash 的 PowerShell 命令,$变量 会先被外层双引号展开、
7
+ * heredoc 也会在那一层失效(实测:pwsh -Command "Write-Output \"var=$abc\"" 输出 var=)。
8
+ * 原生 pwsh 工具本身不吃 $,但受 Windows 命令行长度限制(实测 32700 字符即 ENAMETOOLONG),
9
+ * 且每次都是全新进程,变量 / 当前目录 / 已导入模块不跨调用保留。
10
+ *
11
+ * 本工具把脚本正文直接经 stdin 喂给一个常驻 pwsh 进程:
12
+ * - 零转义:不经过任何 shell,$ / 引号 / here-string / 中文原样送达
13
+ * - 无长度限制:20 万字符的单次脚本实测跑通(原生工具在同一长度上必撞命令行上限)
14
+ * - 省启动:原生 pwsh 每次冷启动实测约 730ms,这边热态往返约 8ms
15
+ * - 状态保留:变量、当前目录、$env:、已导入模块跨调用保留
16
+ *
17
+ * 与原生 pwsh 工具的关系:并存,不替换。原生工具继续负责需要逐次沙箱、
18
+ * 审批、后台 job 晋升的场景;本工具负责长脚本与需要状态的场景。
19
+ *
20
+ * 沙箱:常驻进程在**启动时**整体包进 ctx.sandbox.confine 返回的受限 argv,
21
+ * 之后它的每一次读/写都受同一个 workspace 边界约束(ACL 受限令牌是进程级,
22
+ * 不是逐命令级——这一点与原生工具等价,实测 workspace 外写入全部被拒)。
23
+ * read-only 模式下 pwsh 以 ConstrainedLanguage 运行,无法构造 runspace,
24
+ * 因此常驻会话在该模式下不启动,直接报错让模型改用原生 pwsh 工具。
25
+ * ctx.sandboxPolicy 缺席时同样拒绝启动(fail-closed),除非显式 config.allowUnconfined: true。
26
+ *
27
+ * 已知边界(已写进工具描述,模型可见)
28
+ * - 脚本内 exit N 只结束当次调用,退出码不可观测(runspace 语义限制)。
29
+ * - 超时 / 取消会终止整个会话进程(连带回收子进程树)并自动重建,状态丢失。
30
+ */
31
+
32
+ import { readFileSync } from 'node:fs'
33
+ import { DEFAULT_MAX_OUTPUT_BYTES, bootArgv, startSession } from './session.mjs'
34
+ import { installReleaseLive, startAutoUpdate } from './auto-update.mjs'
35
+ import { preflightInstall } from './update-apply.mjs'
36
+ import { UpdateChecker } from './update-check.mjs'
37
+
38
+ /** 加载器诊断里显示的插件名。 */
39
+ export const name = 'pwsh-run'
40
+
41
+ /** 注册前必须已有的服务。 */
42
+ export const inject = ['subprocess', 'tools']
43
+
44
+ const DEFAULT_TIMEOUT_MS = 120000
45
+ const DEFAULT_IDLE_MS = 30 * 60 * 1000
46
+ const DEFAULT_MAX_SESSIONS = 4
47
+ /** 调用方超时之外多给的余量:服务端被打断后还要把尾部输出吐完、写结束标记。 */
48
+ const RESPONSE_GRACE_MS = 5000
49
+
50
+ /** 服务端脚本:随插件分发,启动时经 -EncodedCommand 送入,不落地临时文件。 */
51
+ const SERVER_SOURCE = readFileSync(new URL('./session-server.ps1', import.meta.url), 'utf8')
52
+
53
+ const parameters = {
54
+ type: 'object',
55
+ properties: {
56
+ script: {
57
+ type: 'string',
58
+ description: 'The PowerShell script body to run. Sent verbatim to a persistent pwsh session — do not escape $, do not wrap it in pwsh -c.',
59
+ },
60
+ workdir: {
61
+ type: 'string',
62
+ 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.',
63
+ },
64
+ timeoutMs: {
65
+ type: 'number',
66
+ description: 'Timeout for this call in milliseconds; defaults to 120000. On timeout the session process is terminated and rebuilt.',
67
+ },
68
+ },
69
+ required: ['script'],
70
+ additionalProperties: false,
71
+ }
72
+
73
+ /**
74
+ * 解析子进程的启动目录。
75
+ * 与 presets/shared/custom-bash.mjs 同款:workingDirectory 服务优先(会话中途切目录后
76
+ * header.cwd 不会更新),服务缺失时退回 header.cwd,再退回进程 cwd。
77
+ */
78
+ async function resolveCwd(ctx, agent, signal) {
79
+ try {
80
+ const service = ctx.get('workingDirectory')
81
+ if (service !== undefined && agent !== undefined) return await service.ensure(agent, signal)
82
+ } catch {
83
+ // 服务异常时退回旧路径,不让一次目录查询拖垮工具调用。
84
+ }
85
+ const header = agent?.session?.header
86
+ if (typeof header?.cwd === 'string' && header.cwd.length > 0) return header.cwd
87
+ return process.cwd()
88
+ }
89
+
90
+ /**
91
+ * 把一个字符串安全地拼进 PowerShell 源码。
92
+ * 必须用单引号字面量:JSON 的双引号写法会被 PowerShell 当成可展开字符串,
93
+ * 路径里一旦出现 $ 或反引号(如 D:\$Recycle.Bin)就会被解析掉,
94
+ * 而且 JSON 会把反斜杠加倍成 \\,与 PowerShell 的转义规则并不一致。
95
+ * PowerShell 单引号里唯一需要转义的字符是单引号本身(写成两个)。
96
+ */
97
+ function psLiteral(value) {
98
+ return "'" + String(value).replaceAll("'", "''") + "'"
99
+ }
100
+
101
+ /**
102
+ * confine 失败时把原始 Win32 错误翻译成能照着做的说明。
103
+ * 最常见的一种:工作区根的 DACL 没给 WRITE_DAC / WRITE_OWNER。沙箱要在目录上写 DACL 与
104
+ * SACL(低完整性标签),缺任一条就会以 Win32 5(拒绝访问)失败,而原始错误只有一句
105
+ * SetNamedSecurityInfoW failed,看不出该改哪个目录、该给什么权限。
106
+ */
107
+ function describeConfineFailure(error, workspaceRoot) {
108
+ const raw = String(error?.message ?? error)
109
+ if (!/SetNamedSecurityInfoW/.test(raw)) return 'pwsh_run could not confine the session: ' + raw
110
+ const root = typeof workspaceRoot === 'string' ? workspaceRoot : '(unknown)'
111
+ return 'pwsh_run could not confine the session: ' + raw
112
+ + '. The sandbox writes a DACL and an integrity label on the workspace root, which needs its DACL to'
113
+ + ' grant the current user WRITE_DAC and WRITE_OWNER (full control satisfies both; Modify alone does not).'
114
+ + ' Workspace root was ' + root + ' — grant yourself full control (icacls "' + root + '" /grant "%USERNAME%":(OI)(CI)F)'
115
+ + ' or start the session from a directory under your user profile.'
116
+ }
117
+
118
+ /**
119
+ * 捕获「子进程起不来」的报错,附上能照着做的说明。
120
+ *
121
+ * DSH 的 Windows 沙箱(workspace-write)给进程挂的是 Low 完整性 + WRITE_RESTRICTED 令牌,
122
+ * 这种令牌建不了匿名管道,于是所有「捕获子进程 stdio」的起法一律失败——`& exe` 也在其中
123
+ * (PowerShell 的调用运算符内部要建管道)。原始报错只有一句「拒绝访问」,
124
+ * 看不出这是沙箱边界而不是命令写错了,模型很容易在这里反复换姿势盲试。
125
+ */
126
+ export function describeExecDenial(text) {
127
+ // 第一道门必须同时认内层文案:服务端交付的是剥到最内层的异常,`& exe` 到这里的原文是
128
+ // "StandardOutputEncoding is only supported when standard output is redirected.",没有
129
+ // "failed to run" 前缀。只按外层文案判定会让这条提示在真实路径上永不出现。
130
+ if (!/failed to run|StandardOutputEncoding is only supported/.test(text)) return null
131
+ if (!/拒绝访问|Access is denied|StandardOutputEncoding is only supported/.test(text)) return null
132
+ return '[The sandbox is blocking that child process: this session runs under a restricted token (Low integrity +'
133
+ + ' WRITE_RESTRICTED) that cannot create anonymous pipes, so any launch that captures a child\'s stdio fails —'
134
+ + ' including `& exe`. Launches that do work: (1) [Diagnostics.Process]::Start on'
135
+ + ' `cmd.exe /c "<exe> <args> > out.txt 2> err.txt"` with UseShellExecute=$false — .ExitCode is correct and'
136
+ + ' both files carry the output; (2) Start-Process -NoNewWindow -PassThru -Wait -RedirectStandardOutput <file>'
137
+ + ' -RedirectStandardError <file> — output is captured, but .ExitCode comes back empty. Two things NOT to do:'
138
+ + ' do not pass CreateNoWindow=$true, and do not call Start-Process without -NoNewWindow — a fresh console'
139
+ + ' cannot initialize under the restricted token, so the child dies with STATUS_DLL_INIT_FAILED (0xC0000142)'
140
+ + ' and Windows raises a modal error dialog. Also do not let a child inherit this session\'s stdout: this tool'
141
+ + ' detaches the child std handles, so session corruption is prevented — the lost output is not.]'
142
+ }
143
+
144
+ /** 合并调用方取消信号与本地超时信号。 */
145
+ function withTimeout(signal, timeoutMs) {
146
+ const timeout = AbortSignal.timeout(timeoutMs)
147
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout])
148
+ }
149
+
150
+ export function apply(ctx, config) {
151
+ const pwshPath = typeof config?.pwshPath === 'string' && config.pwshPath.length > 0 ? config.pwshPath : 'pwsh'
152
+ const defaultTimeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
153
+ const idleMs = Number.isSafeInteger(config?.idleMs) && config.idleMs > 0 ? config.idleMs : DEFAULT_IDLE_MS
154
+ const maxSessions = Number.isSafeInteger(config?.maxSessions) && config.maxSessions > 0 ? config.maxSessions : DEFAULT_MAX_SESSIONS
155
+ const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
156
+ // 明确放弃边界的唯一开关:策略服务缺席时默认拒绝启动,只有这里能改主意。
157
+ const allowUnconfined = config?.allowUnconfined === true
158
+ // 更新检查:查到新版本时在当次调用的输出末尾挂一行。
159
+ const updateCheck = config?.updateCheck !== false
160
+ // 自动更新:查到新版本就自己装上,装上即生效(HMR 在场时无需重启)。默认开——
161
+ // 用户装这个插件图的是「命令不再被转义」,不该再手动盯版本。
162
+ const autoUpdate = config?.autoUpdate !== false
163
+
164
+ // 沙箱服务每次启动会话时再取:插件 apply 可能早于沙箱服务挂载,
165
+ // 而沙箱缺席时插件仍需能加载(启动会话时给出明确错误即可)。
166
+ // 入参是 Agent(不是 ToolExecution):sandboxPolicy.resolve 需要 session 才能读到
167
+ // 会话的 sandbox/mode 覆盖与 header.cwd。传错形状会静默退回部署默认值
168
+ // (mode=workspace-write + 配置的 workspaceRoot),边界就跑到 profile 目录去了。
169
+ const resolvePolicy = (agent) => {
170
+ const sandboxPolicy = ctx.get('sandboxPolicy')
171
+ if (sandboxPolicy === undefined) return null
172
+ const session = agent?.session
173
+ return sandboxPolicy.resolve(session === undefined ? {} : { session })
174
+ }
175
+
176
+ /**
177
+ * 安装闸门:自动更新与手动安装共用。
178
+ *
179
+ * 热替换会执行本插件的 effect 清理函数,那里 dispose 掉全部常驻会话——正在跑的脚本
180
+ * 会连人带马被掐断,与直接断电无异。所以安装前先确认没有调用在跑;置位与检查必须在
181
+ * 同一个同步块里完成,否则中间会挤进一条新调用。
182
+ */
183
+ let activeRuns = 0
184
+ let installing = false
185
+
186
+ /** sessionId -> Promise<PwshSession>。用 Promise 占位,避免并发首调用各起一个进程。 */
187
+ const sessions = new Map()
188
+
189
+ const sessionKey = (exec) => {
190
+ const id = exec?.agent?.session?.id
191
+ return typeof id === 'string' && id.length > 0 ? id : 'default'
192
+ }
193
+
194
+ const evict = async (key, session) => {
195
+ sessions.delete(key)
196
+ if (session !== undefined && session !== null) await session.dispose()
197
+ }
198
+
199
+ /**
200
+ * 申请安装权:只有「没有调用在跑、也没有别人在装」时才拿得到。
201
+ *
202
+ * 检查与置位放在同一个同步块里,中间不会挤进新调用——否则就会出现
203
+ * 「刚判断空闲、一条调用起来、安装把它的会话掐掉」这种时序空档。
204
+ */
205
+ const beginInstall = () => {
206
+ if (installing || activeRuns > 0) return false
207
+ installing = true
208
+ return true
209
+ }
210
+ const endInstall = () => { installing = false }
211
+ /** 交给自动更新循环:忙就别装,推迟整轮。 */
212
+ const installGate = { tryAcquire: beginInstall, release: endInstall }
213
+
214
+ /**
215
+ * 安装进行中到达的调用先等着:此刻建出来的会话会在热替换那一刻被 dispose,
216
+ * 等于刚起就断。等到安装收尾再继续——之后的会话属于安装后的世界。
217
+ */
218
+ const waitForInstall = async () => {
219
+ const deadline = Date.now() + 120000
220
+ while (installing) {
221
+ if (Date.now() > deadline) throw new Error('pwsh_run: an update is being installed and has not finished; retry in a moment')
222
+ await new Promise((resolve) => setTimeout(resolve, 100))
223
+ }
224
+ }
225
+
226
+ /** 取一个活着的会话,必要时新建;超出上限淘汰最久未用的。 */
227
+ const acquire = async (key, workdir, agent, signal) => {
228
+ const existing = sessions.get(key)
229
+ if (existing !== undefined) {
230
+ let session = null
231
+ try {
232
+ session = await existing
233
+ } catch {
234
+ sessions.delete(key)
235
+ }
236
+ if (session !== null && !session.dead) {
237
+ session.lastUsed = Date.now()
238
+ return session
239
+ }
240
+ if (session !== null) await session.dispose()
241
+ sessions.delete(key)
242
+ }
243
+
244
+ const cwd = workdir ?? await resolveCwd(ctx, agent, signal)
245
+ const pending = (async () => {
246
+ const executable = await ctx.subprocess.resolveExecutable(pwshPath, undefined, signal)
247
+ // 常驻进程只在启动时能 confine 一次——但 ACL 受限令牌是**进程级**的,
248
+ // 之后该进程所有读写都受同一 workspace 边界约束,与原生 pwsh 工具等价(实测)。
249
+ const policy = resolvePolicy(agent)
250
+ const boot = bootArgv(SERVER_SOURCE)
251
+ const plain = [executable].concat(boot)
252
+ // fail-closed:解析不出边界就拒绝启动,而不是悄悄跑一个不受约束的会话。
253
+ if (policy === null && allowUnconfined !== true) {
254
+ throw new Error('pwsh_run: ctx.sandboxPolicy is absent, so the workspace boundary cannot be resolved. ' +
255
+ 'Mount @deepseek-ai/dsh-sandbox-policy, or set config.allowUnconfined: true to accept an unconfined session ' +
256
+ '(file writes would then NOT be restricted to the workspace).')
257
+ }
258
+ if (policy === null || policy.mode === 'danger-full-access') {
259
+ return await startSession(ctx.subprocess, { executable, cwd, serverSource: SERVER_SOURCE, label: key, maxOutputBytes })
260
+ }
261
+ // read-only 下 pwsh 是 ConstrainedLanguage,连 runspace 都建不起来(实测
262
+ // [Text.UTF8Encoding]::new 即报 "Cannot create type"),常驻会话没法启动。
263
+ if (policy.mode === 'read-only') {
264
+ throw new Error('pwsh_run does not support read-only mode: a persistent pwsh session needs FullLanguage ' +
265
+ '(a read-only sandbox runs pwsh in ConstrainedLanguage, where creating a runspace fails). Use the native pwsh tool instead.')
266
+ }
267
+ const sandbox = ctx.get('sandbox')
268
+ 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.')
269
+ let confined
270
+ try {
271
+ confined = await sandbox.confine(plain, policy, signal)
272
+ } catch (error) {
273
+ throw new Error(describeConfineFailure(error, policy.workspaceRoot))
274
+ }
275
+ return await startSession(ctx.subprocess, { executable, argv: confined.argv, cwd, serverSource: SERVER_SOURCE, label: key, maxOutputBytes })
276
+ })()
277
+ pending.catch(() => sessions.delete(key))
278
+ sessions.set(key, pending)
279
+
280
+ if (sessions.size > maxSessions) {
281
+ const others = [...sessions.entries()].filter(([victim]) => victim !== key)
282
+ const ages = []
283
+ for (const entry of others) {
284
+ let lastUsed = 0
285
+ try {
286
+ lastUsed = (await entry[1]).lastUsed
287
+ } catch {
288
+ lastUsed = 0
289
+ }
290
+ ages.push([entry[0], entry[1], lastUsed])
291
+ }
292
+ ages.sort((a, b) => a[2] - b[2])
293
+ for (const [victimKey, victim] of ages.slice(0, Math.max(0, sessions.size - maxSessions))) {
294
+ sessions.delete(victimKey)
295
+ Promise.resolve(victim).then((session) => session.dispose(), () => undefined)
296
+ }
297
+ }
298
+ return await pending
299
+ }
300
+
301
+ // 空闲会话回收,避免常驻进程无限累积。
302
+ const reaper = setInterval(() => {
303
+ const now = Date.now()
304
+ for (const [key, pending] of [...sessions.entries()]) {
305
+ Promise.resolve(pending).then(async (session) => {
306
+ if (now - session.lastUsed > idleMs) await evict(key, session)
307
+ }, () => sessions.delete(key))
308
+ }
309
+ }, Math.min(idleMs, 60000))
310
+ if (typeof reaper.unref === 'function') reaper.unref()
311
+
312
+ /** 插件卸载 / 热重载时清干净:停回收器 + 杀掉所有会话。 */
313
+ ctx.effect(() => () => {
314
+ clearInterval(reaper)
315
+ for (const pending of sessions.values()) Promise.resolve(pending).then((session) => session.dispose(), () => undefined)
316
+ sessions.clear()
317
+ }, 'pwsh-run.sessions')
318
+
319
+ // 更新检查:只读 npm registry 的 dist-tags、只记判决,从不自己安装。
320
+ // 发现新版本时不改工具描述(描述在注册时就固定了),而是把提示挂在每次调用的
321
+ // 输出末尾一次——够模型看见,也不至于每次刷。
322
+ const checker = updateCheck
323
+ ? new UpdateChecker({
324
+ log: (line) => {
325
+ try {
326
+ ctx.logger?.info?.('pwsh-run: %s', line)
327
+ } catch {
328
+ // 日志服务缺席时不影响工具本身。
329
+ }
330
+ },
331
+ })
332
+ : undefined
333
+ // 自动更新循环自成一个 effect:它比 checker 多一层「装上即生效」的动作,
334
+ // 失败要退避、要记装过的版本,这些都在 auto-update.mjs 里。
335
+ ctx.effect(() => {
336
+ const stopAuto = autoUpdate && checker !== undefined
337
+ ? startAutoUpdate(ctx, checker, {
338
+ log: (line) => { try { ctx.logger?.info?.('pwsh-run: %s', line) } catch { /* 日志缺席不影响更新 */ } },
339
+ installGate,
340
+ ...(Number.isSafeInteger(config?.autoUpdateDelayMs) && config.autoUpdateDelayMs >= 0 ? { firstDelayMs: config.autoUpdateDelayMs } : {}),
341
+ })
342
+ : undefined
343
+ return () => stopAuto?.()
344
+ }, 'pwsh-run.auto-update')
345
+
346
+ // 提示用的定时检查只在自动更新关闭时才起:自动更新的循环自己就会调 check(),
347
+ // 两边都开着等于每 12 小时查两次。
348
+ ctx.effect(() => {
349
+ if (checker === undefined || autoUpdate) return undefined
350
+ checker.start()
351
+ return () => checker.stop()
352
+ }, 'pwsh-run.update')
353
+
354
+ /**
355
+ * 更新工具:把「查到」与「装」拆成同一入口的两个动作。
356
+ *
357
+ * 为什么要有它
358
+ * 只把提示挂在 pwsh_run 输出末尾时,模型看见了也无从下手(它拿不到 profile 名,
359
+ * 也不知道该不该重启)。给出一个能被调用的动作,比给一句话可靠。
360
+ *
361
+ * 为什么装这一步不自己下载
362
+ * 见 update-apply.mjs 的头注释:本插件是 profile 的一个 bundle 依赖,版本钉在
363
+ * package.json 与 pnpm-lock.yaml 里,绕开包管理器改文件会让 lockfile 与磁盘不一致。
364
+ */
365
+ ctx.effect(() => ctx.tools.register({
366
+ name: 'pwsh_run_update',
367
+ description: [
368
+ 'Check for, and optionally install, a newer release of the pwsh_run plugin.',
369
+ '* action "check" reads the npm registry dist-tags once and reports the verdict; it never installs anything.',
370
+ '* action "install" hands the pinned version spec (<package>@X.Y.Z) to DSH\'s own plugin manager (the same service the Plugins page uses), which runs pnpm and rolls back on failure.',
371
+ '* Installing writes the package to the profile. Where the host runs its HMR service, the new code is already live when this returns; otherwise the running process keeps the old modules until DSH restarts. The result says which of the two happened.',
372
+ '* Only published stable versions are considered; pre-releases are never pushed. The version actually installed is read back from disk, because pnpm\'s minimumReleaseAge policy can install an older release than the one requested.',
373
+ '* An install replaces this plugin in place, which terminates the persistent pwsh sessions. It is therefore refused while a pwsh_run call is running; retry after that call finishes (the automatic updater applies the same rule on its own).',
374
+ ].join('\n'),
375
+ parameters: {
376
+ type: 'object',
377
+ properties: {
378
+ action: {
379
+ type: 'string',
380
+ enum: ['check', 'install'],
381
+ description: 'check (default): report whether a newer release exists. install: also install the newest release.',
382
+ },
383
+ },
384
+ additionalProperties: false,
385
+ },
386
+ output: {
387
+ schema: {
388
+ type: 'object',
389
+ additionalProperties: false,
390
+ properties: { text: { type: 'string' } },
391
+ required: ['text'],
392
+ },
393
+ render: (_args, value) => [{ type: 'text', text: value.text }],
394
+ },
395
+ async execute(args) {
396
+ if (checker === undefined) {
397
+ return { text: 'pwsh_run update checks are disabled in this profile (config.updateCheck: false), so pwsh_run_update has nothing to do.' }
398
+ }
399
+ const action = args?.action === 'install' ? 'install' : 'check'
400
+ const state = await checker.check()
401
+ const doc = checker.document()
402
+ const lines = ['state: ' + String(doc.state)]
403
+ if (doc.running !== undefined) lines.push('running: ' + doc.running)
404
+ if (doc.latest !== undefined) lines.push('newest release: ' + doc.latest)
405
+ if (doc.spec !== undefined) lines.push('install spec: ' + doc.spec)
406
+ if (doc.error !== undefined) lines.push('error: ' + String(doc.error))
407
+ if (state.state !== 'available') {
408
+ // failed 与 current 必须分开说:查不到的时候说「已是最新」是句谎话。
409
+ if (state.state === 'current') {
410
+ lines.push(action === 'install'
411
+ ? 'Nothing to install: this is the newest release the source publishes.'
412
+ : 'No newer release.')
413
+ }
414
+ else {
415
+ // 这条分支上 doc.spec 是 undefined,插值只会印出一个字面占位符,所以给模板。
416
+ lines.push('Could not reach the npm registry, so whether a newer version exists is unknown. '
417
+ + 'Run the check again later, or upgrade by hand: dsh plugin --profile <name> add "<package>@X.Y.Z"')
418
+ }
419
+ return { text: lines.join('\n') }
420
+ }
421
+ if (action === 'check') {
422
+ lines.push('Run this again with action "install", or run: dsh plugin --profile <name> add "' + String(doc.spec) + '"')
423
+ return { text: lines.join('\n') }
424
+ }
425
+ // 装之前先问一句值不值得装:磁盘上已经是这个版本、或者这个 tag 的包根本没改过版本号,
426
+ // 装下去只会被 plugin-manager 判 already-installed。判掉后照实说清是哪一种。
427
+ const unnecessary = preflightInstall(ctx, checker, String(doc.spec))
428
+ if (unnecessary !== null) {
429
+ lines.push('install: ' + unnecessary.outcome)
430
+ lines.push(unnecessary.text)
431
+ return { text: lines.join('\n') }
432
+ }
433
+ // 安装会热替换本插件,从而 dispose 掉所有常驻会话。正有调用在跑时先不装:
434
+ // 掐断别人跑到一半的脚本,代价远大于晚几十秒。
435
+ if (!beginInstall()) {
436
+ lines.push('install: postponed')
437
+ lines.push('A pwsh_run call is running right now, and installing would terminate its session mid-script. '
438
+ + 'Retry once that call has finished; auto-update performs this same check on its own.')
439
+ return { text: lines.join('\n') }
440
+ }
441
+ let outcome
442
+ try {
443
+ outcome = await installReleaseLive(ctx, String(doc.spec), { requestId: 'pwsh-run-update' })
444
+ } finally {
445
+ endInstall()
446
+ }
447
+ lines.push('install: ' + outcome.outcome)
448
+ lines.push(outcome.text)
449
+ if (outcome.installedVersion !== undefined && outcome.installedVersion !== doc.latest) {
450
+ lines.push('Installed version on disk: ' + outcome.installedVersion + ' (requested ' + String(doc.latest) + ').')
451
+ }
452
+ if (outcome.ok !== true && doc.spec !== undefined) lines.push('Manual path: dsh plugin --profile <name> add "' + String(doc.spec) + '"')
453
+ return { text: lines.join('\n') }
454
+ },
455
+ }), 'pwsh-run.update-tool')
456
+
457
+ /** 一条调用的实体;activeRuns 由注册处的 execute 在外面加减。 */
458
+ const runScript = async (args, exec) => {
459
+ await waitForInstall()
460
+ const requested = Number.isFinite(args?.timeoutMs) && args.timeoutMs > 0 ? Math.trunc(args.timeoutMs) : defaultTimeoutMs
461
+ const key = sessionKey(exec)
462
+ const workdir = typeof args?.workdir === 'string' && args.workdir.length > 0 ? args.workdir : undefined
463
+
464
+ // workdir 通过 Set-Location 生效(会话有状态,所以下一次调用会继承)。
465
+ const script = workdir === undefined
466
+ ? args.script
467
+ : 'Set-Location -LiteralPath ' + psLiteral(workdir) + " -ErrorAction Stop\n" + args.script
468
+
469
+ let session
470
+ try {
471
+ session = await acquire(key, workdir, exec?.agent, exec?.signal)
472
+ } catch (error) {
473
+ // acquire 里已经写好人话的错误(沙箱缺席、read-only、confine 失败)不再套第二层前缀。
474
+ // 用 \b 而不是 'pwsh_run ':几条自带文案是 'pwsh_run: ...' 冒号收尾,带空格的匹配会漏掉它们。
475
+ const message = String(error?.message ?? error)
476
+ throw new Error(/^pwsh_run\b/.test(message) ? message : 'pwsh_run could not start a session: ' + message)
477
+ }
478
+
479
+ const local = withTimeout(exec?.signal, requested + RESPONSE_GRACE_MS)
480
+ let expired = false
481
+ const onAbort = () => { expired = true }
482
+ local.addEventListener('abort', onAbort, { once: true })
483
+
484
+ let response = null
485
+ try {
486
+ response = await Promise.race([
487
+ session.run(script, requested),
488
+ new Promise((resolve) => {
489
+ local.addEventListener('abort', () => resolve(null), { once: true })
490
+ }),
491
+ ])
492
+ } catch (error) {
493
+ // 会话中途死亡:清掉,下一次调用自动重建。
494
+ await evict(key, session)
495
+ const message = String(error)
496
+ const note = session.dead ? '[' + message + '; a new session starts on the next call]' : '[' + message + ']'
497
+ return { text: note }
498
+ } finally {
499
+ local.removeEventListener('abort', onAbort)
500
+ }
501
+
502
+ const notes = []
503
+ if (response?.truncated === true) notes.push('[output truncated at ' + String(maxOutputBytes) + ' bytes; redirect to a file if you need it all]')
504
+ if (response === null || expired) {
505
+ // 服务端没能在预算内回话:整个进程杀掉重建,不让一条调用拖垮后续。
506
+ await evict(key, session)
507
+ notes.push(exec?.signal?.aborted === true
508
+ ? '[cancelled; the session was terminated and is rebuilt on the next call]'
509
+ : '[no response from the pwsh session within ' + String(Math.round(requested / 1000)) + 's; the session was terminated and is rebuilt on the next call]')
510
+ } else if (session.dead) {
511
+ await evict(key, session)
512
+ notes.push('[' + String(session.deathReason) + '; a new session starts on the next call]')
513
+ } else if (response.timedOut) {
514
+ // 服务端 Stop() 能打断脚本,但脚本内 Start-Process 起的子进程不会随之回收
515
+ // (实测子进程仍存活)。杀掉整个会话才能靠 Job Object 连带清干净。
516
+ await evict(key, session)
517
+ 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]')
518
+ }
519
+
520
+ const body = []
521
+ for (const line of response?.out ?? []) body.push(line)
522
+ for (const line of response?.warn ?? []) body.push('WARNING: ' + line)
523
+ for (const line of response?.verbose ?? []) body.push('VERBOSE: ' + line)
524
+ if (response?.sentinel === 'threw') body.push('[script threw]')
525
+ // 更新提示只在第一次查到新版本时挂一行,之后不再刷。自动更新关掉时给出可照做的命令,
526
+ // 打开时说清楚它自己会装——否则模型会把这个版本当成「需要人工干预」。
527
+ const fresh = checker?.announce
528
+ if (fresh !== undefined && autoUpdate === false) {
529
+ notes.push('[a newer pwsh-run release exists: ' + fresh.latest + ' (running ' + String(checker?.version ?? '?') +
530
+ '). Install it with dsh plugin --profile <name> add "' + fresh.spec + '". ' +
531
+ 'Auto-update is off in this profile (config.autoUpdate: false).]')
532
+ }
533
+ const text = [body.join('\n')]
534
+ .concat(response?.err ?? [])
535
+ .concat(response?.exceptions ?? [])
536
+ .concat(response?.fatal ?? [])
537
+ .join('\n')
538
+ const denial = describeExecDenial(text)
539
+ if (denial !== null) notes.push(denial)
540
+ const all = [text].concat(notes).filter((part) => part.length > 0).join('\n')
541
+ return { text: all.length > 0 ? all : '(no output)' }
542
+ }
543
+
544
+ // 工具注册也要挂到 effect 上:live patchReload 会重新 apply 本模块,
545
+ // 旧注册不撤的话 ctx.tools.register 会抛 "already registered"。
546
+ ctx.effect(() => ctx.tools.register({
547
+ name: 'pwsh_run',
548
+ description: [
549
+ 'Run a PowerShell script in a **persistent** pwsh 7 session.',
550
+ '* 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.',
551
+ '* 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.',
552
+ '* 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.',
553
+ '* Output ordering is preserved and streams are labelled (stdout / error / warning / verbose).',
554
+ '* 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.',
555
+ '* exit N inside the script only ends that call; its exit code is NOT observable. Use $LASTEXITCODE or Write-Output instead.',
556
+ '* 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.',
557
+ '* Not for interactive programs or anything that needs a TTY.',
558
+ ].join('\n'),
559
+ parameters,
560
+ output: {
561
+ schema: {
562
+ type: 'object',
563
+ additionalProperties: false,
564
+ properties: { text: { type: 'string' } },
565
+ required: ['text'],
566
+ },
567
+ render: (_args, value) => [{ type: 'text', text: value.text }],
568
+ },
569
+ async execute(args, exec) {
570
+ // 自动更新与手动安装都要先看这个计数:热替换会 dispose 全部会话,
571
+ // 正跑着的脚本会被连人带马掐断。
572
+ activeRuns += 1
573
+ try {
574
+ return await runScript(args, exec)
575
+ } finally {
576
+ activeRuns -= 1
577
+ }
578
+ },
579
+ }), 'pwsh-run.tool')
580
+ }