dsh-pwsh-plugin 0.2.17 → 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.
@@ -6,28 +6,32 @@ Implementation details and historical measurements follow. Behavior depends on t
6
6
 
7
7
  ## Known limits
8
8
 
9
- - **`read-only` mode is unsupported** (see above); use the built-in `pwsh` tool there.
9
+ - **`read-only` mode is unsupported**; use the built-in `pwsh` tool there.
10
10
  - **Refuses to start when the sandbox policy service is absent**, unless `allowUnconfined: true`.
11
11
  - **A timeout kills the whole session** (and reaps its child process tree). State is lost; the next call
12
12
  rebuilds automatically.
13
13
  - **`exit N` exit codes are not observable** (runspace semantics).
14
- - **stdout / stderr interleaving is not preserved** — everything goes through `*>&1` and is tagged by type.
15
- - **`[Console]::Error.WriteLine` is swallowed** (the server points `Console.Error` at an in-memory stream so
16
- the protocol cannot be polluted). A child process's stderr, `Write-Error` and `throw` all come back fine.
17
- - **Child std handles are detached from the protocol.** `Console::SetIn/SetOut` only changes the .NET layer, so
18
- the server also rewrites the three Win32 slots: stdin goes to `NUL`, stdout and stderr go to a
19
- `FILE_FLAG_DELETE_ON_CLOSE` temp file truncated before every call. A grandchild that writes to inherited
20
- stdout can no longer inject a fake protocol line (measured: one `Write-Output '<<<BYE>>>'` from a child used
21
- to kill the session), and one that reads inherited stdin gets EOF instead of the caller's request.
22
- The replacement handles are created **inheritable** (`SECURITY_ATTRIBUTES.bInheritHandle`): a handle made with
23
- a `NULL` security descriptor only exists in this process, so a child that trusts `STARTUPINFO.hStdInput`
24
- receives a bare handle *number* that means nothing over there — `GetFileType` reports `UNKNOWN`, and once in
25
- a few dozen launches that number is recycled by an unrelated object and the read returns its bytes.
26
- stdout deliberately does **not** go to `NUL`: that is a character device, PowerShell then believes its output
27
- is not redirected and the sandbox denial changes into a useless `StandardOutputEncoding` message.
28
- If the temp directory is unwritable it falls back to `NUL` and says so once, in a warning on the first call.
29
- - **A session is serial**: concurrent calls queue up and run one at a time (no interleaved output, but no
30
- parallel speedup either).
14
+ - **stdout / stderr interleaving is not preserved** — streams are collected separately and grouped by type.
15
+ - **Direct Console output is discarded.** `Console.Out` and `Console.Error` use null writers rather than
16
+ retained memory streams. On Windows, inheritable `NUL` handles replace stdin/stdout/stderr so a child
17
+ cannot read requests or inject protocol lines. PowerShell-captured native output, `Write-Error` and
18
+ `throw` still return; launches that directly inherit the detached handles lose their output.
19
+ Handle-detachment failure refuses startup. `NUL` can change native-launch denial wording, not permissions.
20
+ - **A session is serial**: creation and execution share one queue per DSH ID. The execution timeout
21
+ excludes queue waiting. Cancelling a queued request affects only itself; active cancellation kills its process.
22
+ - **Reuse rechecks boundaries**: changes to effective policy, workspace or sandbox backend rebuild the
23
+ process. A missing session ID is rejected instead of sharing a default process.
24
+ - **Pool pressure and idle expiry never evict busy sessions.** A full busy pool refuses new sessions
25
+ with a retry message. Idle age starts after the last call completes.
26
+ - **UTF-8 payload budgets are enforced on both sides.** Ordinary streams share `maxOutputBytes`; errors
27
+ retain a separate 64KiB budget, including record separators. Consumed records and undisplayed progress
28
+ records are drained immediately. Frames are size-limited; display prefixes are outside the payload budget.
29
+ - **Top-level syntax is retained.** Workdir uses a separate API call and objects use
30
+ `Out-String -Stream -Width 200`. `#requires` checks version, PSEdition, modules and administrator identity
31
+ before the body; `-ShellId` / `-Assembly` are explicitly refused. Administrator group membership alone
32
+ does not prove the process is unconfined.
33
+ - **Startup failure cleans up and returns the stderr tail.** Handshake warnings appear once on the first
34
+ user call. The gzip bootstrap must remain within the encoded Windows command-line budget or startup is refused.
31
35
  - **No WSL is required.** `pwsh_run` never touches WSL; only `wsl/custom-bash.mjs` does. Without a usable
32
36
  `bashPath`, applying that preset still succeeds (the executable is resolved lazily) and the `bash` tool
33
37
  fails per call with `command "<path>" is not an executable file` — a clear failure, not a silent
@@ -54,4 +58,4 @@ Implementation details and historical measurements follow. Behavior depends on t
54
58
 
55
59
  Nothing asks whether a call is in flight. The host performs the update; this plugin does not get a say and
56
60
  does not gate it. The version is worth more than the session state, but it is not "nothing happened" — do
57
- not hold unrecoverable state in a session across an upgrade.
61
+ not hold unrecoverable state in a session across an upgrade.
@@ -6,23 +6,25 @@
6
6
 
7
7
  ## 已知边界
8
8
 
9
- - **不支持 `read-only` 模式**(见上),用内置 `pwsh`。
9
+ - **不支持 `read-only` 模式**,用内置 `pwsh`。
10
10
  - **沙箱策略服务缺席时拒绝启动**,除非 `allowUnconfined: true`。
11
11
  - **超时会杀掉整个会话**(连带回收子进程树)。状态丢失,下次调用自动重建。
12
12
  - **`exit N` 的退出码不可观测**(runspace 语义)。
13
- - **stdout / stderr 的逐行时序不保留** —— 全部流经 `*>&1` 合并后按类型打标。
14
- - **`[Console]::Error.WriteLine` 会被吞掉**(服务端为防协议污染把 `Console.Error` 指向内存流)。
15
- 子进程的 stderr、`Write-Error`、`throw` 都正常回来。
16
- - **子进程的 std handle 已与协议流断开。** `Console::SetIn/SetOut` 只改 .NET 层,服务端还改写了三个 Win32 槽位:
17
- stdin 接 `NUL`,stdout / stderr 接一个带 `FILE_FLAG_DELETE_ON_CLOSE` 的临时文件、每次调用前截回零。
18
- 于是子进程往继承来的 stdout 写东西不会再伪造出协议行(实测:子进程一句 `Write-Output '<<<BYE>>>'`
19
- 曾让会话当场死亡),读继承来的 stdin 也只会读到 EOF,而不是调用方的请求。
20
- 替上去的句柄必须是**可继承**的(`SECURITY_ATTRIBUTES.bInheritHandle`):用 `NULL` 安全描述符建的句柄
21
- 只存在于本进程,子进程按 `STARTUPINFO.hStdInput` 拿到的只是一个在那边没有意义的句柄**编号** ——
22
- `GetFileType` 报 `UNKNOWN`,几十次里还有一次那个编号被别的对象复用,读出来是别人的字节。
23
- stdout **故意不接 `NUL`**:那是字符设备,PowerShell 会认为输出没被重定向,沙箱拒绝的报错会变成一句
24
- 没用的 `StandardOutputEncoding` 提示。临时目录不可写时退回 `NUL`,并在第一次调用的警告里说明一次。
25
- - **会话是串行的**:并发调用排成队列逐个执行(不会串输出,但也没有并行加速)。
13
+ - **stdout / stderr 的逐行时序不保留** —— 流分别收集,最终按类型分组。
14
+ - **直接 Console 输出会被丢弃**:`Console.Out` / `Console.Error` 使用空输出流,不再积存在内存里。
15
+ Windows 的继承 stdin/stdout/stderr 接可继承的 `NUL` 句柄;子进程不能读请求或向协议注入假行。
16
+ PowerShell 正常捕获的原生输出、`Write-Error`、`throw` 仍返回;主动绕过 PowerShell 捕获而继承句柄的输出会丢失。
17
+ 句柄隔离失败时拒绝启动,不允许降级继续。`NUL` 可能改变原生启动被拒时的文案,不改变权限边界。
18
+ - **会话是串行的**:同一 DSH ID 的创建与执行共用队列。超时从实际发送开始计;排队取消只取消自身,执行中取消才终止进程。
19
+ - **复用前重新核对边界**:有效策略、工作区或沙箱后端变化后重建。缺少 session ID 会拒绝,不能共享默认状态。
20
+ - **忙会话不会因池满或空闲计时被淘汰**:满池全忙时新调用收到重试说明;完成调用后才计算闲置。
21
+ - **输出正文按 UTF-8 字节限制**:普通流共用 `maxOutputBytes`,错误另留 64KiB,记录换行也计数。
22
+ 服务端立即移除消费过的记录;进度记录不显示且及时清空。单帧有长度限制,显示前缀不计入正文预算。
23
+ - **脚本顶层语义保留**:正文单独执行,工作目录通过 API 设置;对象走 `Out-String -Stream -Width 200`。
24
+ `#requires` 的版本、PSEdition、模块和管理员身份会在正文前核验;`-ShellId` / `-Assembly` 明确拒绝。
25
+ 管理员身份检查依据当前令牌的有效组身份,不能据此判断沙箱是否存在。
26
+ - **启动失败会清理子进程并带回 stderr 尾部**;握手警告在第一条用户调用显示一次。启动器采用 gzip 压缩,
27
+ 编码后仍须在 Windows 命令行预算以内,否则启动前明确拒绝。
26
28
  - **不依赖 WSL。** `pwsh_run` 全程不碰 WSL,只有 `wsl/custom-bash.mjs` 才碰。没有可用的 `bashPath` 时,
27
29
  该预设照样加载成功(可执行文件是惰性解析的),`bash` 工具每次调用报
28
30
  `command "<路径>" is not an executable file` —— 清晰失败,不会静默降级。默认值是裸名 `bash`;
@@ -42,4 +44,4 @@
42
44
  v0.2.3 → v0.2.4 的一次安装:`$PID` 由 24820 变成 15632,安装前设的 `$global:` 变量读回来是空的。
43
45
 
44
46
  这里没有人问「现在有没有调用在跑」。更新是宿主执行的,本插件既不参与也不设闸门。版本比会话状态值钱,
45
- 但这并不等于「什么都没发生」—— 别把不可重建的状态放在里面跨过升级。
47
+ 但这并不等于「什么都没发生」—— 别把不可重建的状态放在里面跨过升级。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pwsh-plugin",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "type": "module",
5
5
  "description": "Persistent pwsh session tool (pwsh_run) for DeepSeek Harness: run PowerShell scripts with zero escaping, no 32K command-line cap, and state that survives across calls.",
6
6
  "icon": "./icon.svg",
@@ -51,7 +51,7 @@
51
51
  "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0"
52
52
  },
53
53
  "scripts": {
54
- "test": "node test/itest.mjs && node test/plugintest.mjs && node test/denialtest.mjs && node test/metatest.mjs",
55
- "test:all": "node test/itest.mjs && node test/plugintest.mjs && node test/e2e-real-subprocess.mjs && node test/sandboxtest.mjs && node test/bashtest.mjs && node test/bash-e2e.mjs && node test/denialtest.mjs && node test/metatest.mjs"
54
+ "test": "node test/protocoltest.mjs && node test/lifecycletest.mjs && node test/itest.mjs && node test/plugintest.mjs && node test/denialtest.mjs && node test/metatest.mjs",
55
+ "test:all": "node test/protocoltest.mjs && node test/lifecycletest.mjs && node test/itest.mjs && node test/plugintest.mjs && node test/e2e-real-subprocess.mjs && node test/sandboxtest.mjs && node test/bashtest.mjs && node test/bash-e2e.mjs && node test/denialtest.mjs && node test/metatest.mjs"
56
56
  }
57
57
  }
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,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: 'number',
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
- * 与 presets/shared/custom-bash.mjs 同款:workingDirectory 服务优先(会话中途切目录后
70
+ * 与 wsl/custom-bash.mjs 同款:workingDirectory 服务优先(会话中途切目录后
73
71
  * header.cwd 不会更新),服务缺失时退回 header.cwd,再退回进程 cwd。
74
72
  */
75
73
  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
- }
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 = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
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 = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
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
- // 沙箱服务每次启动会话时再取:插件 apply 可能早于沙箱服务挂载,
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
- /** sessionId -> Promise<PwshSession>。用 Promise 占位,避免并发首调用各起一个进程。 */
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 sessionKey = (exec) => {
172
- const id = exec?.agent?.session?.id
173
- return typeof id === 'string' && id.length > 0 ? id : 'default'
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
- 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)
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 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
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
- lastUsed = (await entry[1]).lastUsed
242
- } catch {
243
- lastUsed = 0
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
- ages.push([entry[0], entry[1], lastUsed])
213
+ catch (error) { throw new Error(describeConfineFailure(error, policy.workspaceRoot)) }
246
214
  }
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)
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
- return await pending
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, 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))
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
- if (typeof reaper.unref === 'function') reaper.unref()
238
+ reaper.unref?.()
266
239
 
267
- /** 插件卸载 / 热重载时清干净:停回收器 + 杀掉所有会话。 */
268
240
  ctx.effect(() => () => {
241
+ stopped = true
269
242
  clearInterval(reaper)
270
- for (const pending of sessions.values()) Promise.resolve(pending).then((session) => session.dispose(), () => undefined)
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
- 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
-
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
- session = await acquire(key, workdir, exec?.agent, exec?.signal)
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
- 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)
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?.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]')
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
- 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')
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
- const all = [text].concat(notes).filter((part) => part.length > 0).join('\n')
350
- return { text: all.length > 0 ? all : '(no output)' }
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 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.',
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 runScript(args, exec)
341
+ return await enqueue(args, exec)
380
342
  },
381
343
  }), 'pwsh-run.tool')
382
- }
344
+ }