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.
- package/README.md +46 -20
- package/README.zh-CN.md +39 -15
- package/docs/dsh-windows-schannel.zh-CN.md +118 -0
- 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/docs/runtime-notes.md
CHANGED
|
@@ -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
|
|
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** —
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
a
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- **
|
|
30
|
-
|
|
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`
|
|
9
|
+
- **不支持 `read-only` 模式**,用内置 `pwsh`。
|
|
10
10
|
- **沙箱策略服务缺席时拒绝启动**,除非 `allowUnconfined: true`。
|
|
11
11
|
- **超时会杀掉整个会话**(连带回收子进程树)。状态丢失,下次调用自动重建。
|
|
12
12
|
- **`exit N` 的退出码不可观测**(runspace 语义)。
|
|
13
|
-
- **stdout / stderr 的逐行时序不保留** ——
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
+
}
|