dsh-pwsh-plugin 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,7 +116,7 @@ Three constraints that cost real time to find:
116
116
  will hold two of them; registration is keyed by name, and a second `pwsh_run` is a hard error at boot.
117
117
  `--dump-config` shows the duplicate plainly — that is the check to run after switching.
118
118
 
119
- The three files in `plugin/` (`index.mjs`, `session.mjs`, `session-server.ps1`) must stay together —
119
+ The files in `plugin/` (`index.mjs`, `config.mjs`, `native-pwsh-guard.mjs`, `session.mjs`, `session-server.ps1`) must stay together —
120
120
  `index.mjs` reads the `.ps1` at load time and ships it to the child as `-EncodedCommand`.
121
121
 
122
122
  ## The tools
@@ -146,6 +146,7 @@ requirements are explicitly refused.
146
146
  | `maxSessions` | `4` | Per-plugin process limit, isolated by DSH session ID. If all are busy, new sessions are refused with a retry message. |
147
147
  | `maxOutputBytes` | `8388608` | UTF-8 payload budget per call, including record separators; errors have a separate 65536-byte budget. Framing and display labels are excluded. Truncation is reported. |
148
148
  | `allowUnconfined` | `false` | `true` = start a session even when the sandbox policy service is missing. |
149
+ | `enforcePwshRun` | `false` | Refuse ordinary native `pwsh` calls before execution and direct the model to `pwsh_run`. |
149
150
 
150
151
  `allowUnconfined` is the one deliberate escape hatch. In a normal deployment `ctx.sandboxPolicy` is always
151
152
  present; when it is missing the composition is incomplete, and the plugin **refuses to start** rather than
@@ -154,6 +155,39 @@ quietly running an unconstrained session.
154
155
  Hot reload (`patchReload: live`) re-runs `apply`. Tool registration and process cleanup both hang off
155
156
  `ctx.effect`, so it does not trip `tool "pwsh_run" is already registered`.
156
157
 
158
+ ### Enforce pwsh_run
159
+
160
+ This is a profile-wide plugin setting. Configure it once for all presets and sessions in that profile;
161
+ do not add it to each preset or insert a second `pwsh-run` entry when the bundle already provides one.
162
+
163
+ Open **pwsh settings** in the settings sidebar, enable **Require pwsh_run**, then select **Save**. The plugin row's configuration button opens the same form. Changes stay as a draft until saved; leaving the page discards the draft. **Restore configuration default** removes the profile override.
164
+
165
+ Saving updates only the routing switch through DSH's settings service and preserves running PowerShell processes and session state. The page reports the value accepted by the host, protects against conflicting writes, and disables editing on read-only deployments. It follows the host's Chinese or English language setting.
166
+
167
+ For hosts without the settings UI, override the existing plugin entry in the profile's `cordis.patch.yml`:
168
+
169
+ ```yaml
170
+ - id: pwsh-run
171
+ config:
172
+ enforcePwshRun: true
173
+ ```
174
+
175
+ Ordinary native `pwsh` calls are refused before execution. The response tells the model to move
176
+ `command` into `pwsh_run`'s `script` and preserve `workdir` and `timeoutMs`.
177
+ The command is neither executed first nor replayed automatically. Both native dispatch and
178
+ PTC's `tools.pwsh(...)` pass through this check.
179
+
180
+ Three native entry points remain available: read-only sessions (unsupported by the persistent
181
+ session), jobs with `run_in_background: true`, and per-call escalation requests with a nonempty
182
+ `justification` and a requested mode strictly wider than the current one. Escalation still needs
183
+ host approval; a same-mode flag does not qualify. The guard resolves the actual session policy
184
+ on each call, so mode changes take effect on the next invocation.
185
+
186
+ Disabling the option, removing the guard through hot reload, or uninstalling restores native
187
+ `pwsh` behavior. The option defaults to off. Live configuration requires `ctx.tools.guard()`;
188
+ a host without this API is rejected explicitly so enabling the setting cannot silently go unprotected.
189
+ The guard covers the DSH `pwsh` tool entry, not PowerShell launched inside bash, Node or other scripts.
190
+
157
191
  ## Sandbox
158
192
 
159
193
  The persistent process is wrapped **once, at launch**, in the restricted argv that `ctx.sandbox.confine`
@@ -271,10 +305,15 @@ session — lives in **[docs/runtime-notes.md](docs/runtime-notes.md)**. The sho
271
305
  ```
272
306
  plugin/
273
307
  index.mjs tool registration, sandbox wiring, timeouts
308
+ config.mjs native schema; the routing switch is volatile
309
+ native-pwsh-guard.mjs optional native pwsh invocation guard
274
310
  session.mjs the session object: framing, queue, base64 encode, boot argv
275
311
  session-server.ps1 the PowerShell side: runspace, stream tagging, sentinels
312
+ client/index.js localized settings page using DSH native controls
276
313
  test/
314
+ settingstest.mjs persistence, conflicts, live routing and process preservation
277
315
  support.mjs locates a DSH install; converts Windows paths to /mnt form
316
+ routingtest.mjs native/PTC routing, exceptions, unload and reload
278
317
  protocoltest.mjs frame limits, UTF-8 budgets, startup cleanup
279
318
  lifecycletest.mjs policy changes, session pool, cancellation
280
319
  itest.mjs protocol and session core
@@ -296,6 +335,9 @@ FINDINGS.md every measurement and the reasoning behind the design
296
335
 
297
336
  ```bash
298
337
  cd <checkout>
338
+ pnpm install --ignore-scripts --config.auto-install-peers=false
339
+ node test/settingstest.mjs # 19: native settings form, persistence, routing and state
340
+ node test/routingtest.mjs # 30: native/PTC routing, exceptions, disable and unload
299
341
  node test/protocoltest.mjs # 6: frame limits, UTF-8 budgets, startup cleanup
300
342
  node test/lifecycletest.mjs # 16: policy, pool, concurrency, cancellation, unload
301
343
  PWSH_BIN='pwsh' node test/itest.mjs # 30: protocol and session core
@@ -308,10 +350,11 @@ node test/denialtest.mjs # 11: sandbox-denial hint (no spawn
308
350
  node test/metatest.mjs # 23: plugin-page metadata (no spawn, runs anywhere)
309
351
  ```
310
352
 
311
- Ten scripts, **163 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
353
+ Twelve scripts, **212 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
312
354
  DSH install to borrow `node_modules` from. Tests that need a DSH runtime locate one automatically
313
355
  (`DSH_MODULES`, `DSH_MODULES_ASAR`, or by walking up from the working directory) and print a `SKIP` hint
314
- instead of failing when none is found.
356
+ when none is found. The settings integration test exits unsuccessfully if its host modules are unavailable;
357
+ a release validation must complete every suite without skips.
315
358
 
316
359
  ### Releasing
317
360
 
package/README.zh-CN.md CHANGED
@@ -101,7 +101,7 @@ $env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
101
101
  合成后的配置树里就有两条;注册按名字查重,第二个 `pwsh_run` 在启动时是硬错误。`--dump-config` 一眼就能看出重复 ——
102
102
  切换后跑这一条。
103
103
 
104
- `plugin/` 下三个文件(`index.mjs`、`session.mjs`、`session-server.ps1`)必须放在一起:
104
+ `plugin/` 下的 `index.mjs`、`config.mjs`、`native-pwsh-guard.mjs`、`session.mjs`、`session-server.ps1` 必须放在一起:
105
105
  `index.mjs` 在加载时读那个 `.ps1`,再以 `-EncodedCommand` 送给子进程。
106
106
 
107
107
  ## 工具接口
@@ -129,6 +129,7 @@ Warning、Verbose、Information、Debug 分别带类型前缀,错误和最内
129
129
  | `maxSessions` | `4` | 每个插件实例的会话上限,以 DSH session ID 隔离;全部忙时拒绝新会话并提示重试。 |
130
130
  | `maxOutputBytes` | `8388608` | 每次调用普通输出正文的 UTF-8 字节预算(含记录换行);错误另留 65536 字节。协议与显示标签不计入,截断会明确提示。 |
131
131
  | `allowUnconfined` | `false` | `true` = 沙箱策略服务缺席时也起会话。 |
132
+ | `enforcePwshRun` | `false` | 开启后,在执行前拒绝普通原生 `pwsh` 调用,提示改用 `pwsh_run`。 |
132
133
 
133
134
  `allowUnconfined` 是唯一一条「明知故犯」的开关。正常部署下 `ctx.sandboxPolicy` 一定在;缺席说明组合
134
135
  不完整,这时插件**拒绝启动**,而不是悄悄跑一个不受约束的会话。
@@ -136,6 +137,35 @@ Warning、Verbose、Information、Debug 分别带类型前缀,错误和最内
136
137
  热重载(`patchReload: live`)会重新 `apply`。工具注册与进程清理都挂在 `ctx.effect` 上,不会触发
137
138
  `tool "pwsh_run" is already registered`。
138
139
 
140
+ ### 强制使用 pwsh_run
141
+
142
+ 这是 profile 的公共插件配置:同一 profile 只需设置一次,覆盖其中不同 preset 和会话,
143
+ 不需要在每个 preset 中重复添加。插件已由 bundle 加载时,也不要重复插入 `pwsh-run` 条目。
144
+
145
+ 在设置侧栏打开 **pwsh 设置**,开启 **强制使用 pwsh_run**,再点 **保存**。插件列表的配置按钮也会打开同一张表单。编辑只产生草稿,离开页面会丢弃未保存的修改;**恢复配置默认值** 会移除当前 profile 的覆盖。
146
+
147
+ 保存通过 DSH 设置服务热更新路由开关,保留正在运行的 PowerShell 进程和会话状态。页面显示宿主确认的已保存值,校验配置修订冲突,并在只读部署下禁用修改。页面随宿主语言显示中文或英文。
148
+
149
+ 没有设置界面的宿主仍可在 profile 的 `cordis.patch.yml` 中覆盖已有插件条目:
150
+
151
+ ```yaml
152
+ - id: pwsh-run
153
+ config:
154
+ enforcePwshRun: true
155
+ ```
156
+
157
+ 守卫会在命令执行前拒绝普通原生 `pwsh` 调用,并提示模型把 `command` 改为
158
+ `pwsh_run` 的 `script`;`workdir` 和 `timeoutMs` 保持原值。原生命令不会先执行一次,
159
+ 也不会自动重放。原生工具和 PTC 中的 `tools.pwsh(...)` 都经过同一道检查。
160
+
161
+ 三个必要入口仍交给原生工具:只读会话(常驻会话不支持)、`run_in_background: true`
162
+ 的后台任务、以及带非空 `justification` 且申请权限确实高于当前模式的逐次提权请求。
163
+ 提权仍需宿主审批;同级权限标记不放行。每次按实际会话读取模式,切换权限后立即按新模式判断。
164
+
165
+ 配置关闭、热重载移除守卫或卸载插件后,原生 `pwsh` 恢复原有行为。
166
+ 开关默认关闭。热更新配置需要宿主提供 `ctx.tools.guard()`;缺少此 API 时会明确拒绝加载,避免设置开启后实际没有拦截。
167
+ 它只管理 DSH 的 `pwsh` 工具入口,不解析 `bash`、Node 或其他脚本内启动的 PowerShell。
168
+
139
169
  ## 沙箱
140
170
 
141
171
  常驻进程在**启动时**整体包进 `ctx.sandbox.confine` 返回的受限 argv。Windows ACL 沙箱用的是
@@ -239,10 +269,15 @@ Windows 上打,那份文件到手时是 `0644` —— 探测发现它属于当
239
269
  ```
240
270
  plugin/
241
271
  index.mjs 工具注册、沙箱接线、超时
272
+ config.mjs 宿主配置 schema;路由开关支持热更新
273
+ native-pwsh-guard.mjs 可选的原生 pwsh 调用守卫
242
274
  session.mjs 会话对象:分帧、队列、base64、启动 argv
243
275
  session-server.ps1 PowerShell 侧:runspace、流打标、哨兵
276
+ client/index.js 使用宿主原生控件的双语设置页
244
277
  test/
278
+ settingstest.mjs 配置持久化、冲突、即时路由与进程保留
245
279
  support.mjs 定位 DSH 安装;Windows 路径转 /mnt 形式
280
+ routingtest.mjs 原生与 PTC 路由、必要例外及卸载恢复
246
281
  protocoltest.mjs 分帧、UTF-8 预算、启动清理
247
282
  lifecycletest.mjs 权限切换、会话池与取消
248
283
  itest.mjs 协议与会话本体
@@ -264,6 +299,9 @@ FINDINGS.md 全部实测数据与设计理由
264
299
 
265
300
  ```bash
266
301
  cd <检出目录>
302
+ pnpm install --ignore-scripts --config.auto-install-peers=false
303
+ node test/settingstest.mjs # 19:原生设置表单、持久化、路由和进程保留
304
+ node test/routingtest.mjs # 30:原生/PTC 路由、例外、关闭与卸载
267
305
  node test/protocoltest.mjs # 6:帧边界、字节预算、启动清理
268
306
  node test/lifecycletest.mjs # 16:权限切换、池、并发、取消、卸载
269
307
  PWSH_BIN='pwsh' node test/itest.mjs # 30:协议与会话本体
@@ -276,9 +314,10 @@ node test/denialtest.mjs # 11:沙箱拒绝提示(不起
276
314
  node test/metatest.mjs # 23:插件页元数据(不起子进程,哪儿都能跑)
277
315
  ```
278
316
 
279
- 十份脚本合计 **163 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
317
+ 十二份脚本合计 **212 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
280
318
  `node_modules` 的 DSH 安装。需要 DSH 运行时的测试会自动找(`DSH_MODULES`、`DSH_MODULES_ASAR`,
281
- 或从工作目录逐级向上),找不到时打一行 `SKIP` 提示而不是失败。
319
+ 或从工作目录逐级向上),找不到时打印 `SKIP` 提示。设置集成测试缺少宿主模块时会非零退出;
320
+ 发版验收必须跑完全部测试,不能有跳过。
282
321
 
283
322
  ### 发版
284
323
 
@@ -0,0 +1,94 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-pwsh-plugin',
3
+ factory: (require) => {
4
+ const React = require('react')
5
+ const { SettingsForm, SettingsFormModel, Switch, Button } = require('@deepseek-ai/dsh-client-ui-primitives')
6
+ const h = React.createElement
7
+ const NS = 'pwsh-run'
8
+ const FIELD = 'enforcePwshRun'
9
+ const COPY = {
10
+ zh: {
11
+ title: 'pwsh 设置', group: '工具路由', label: '强制使用 pwsh_run',
12
+ summary: '控制模型是否必须使用常驻 PowerShell 工具。',
13
+ intro: '统一管理当前 profile 的 PowerShell 调用方式。',
14
+ hint: '开启后,普通 pwsh 调用会在执行前被拒绝,并提示模型改用 pwsh_run。',
15
+ scope: '对当前 profile 的所有预设和会话生效。保存后立即应用,正在运行的 pwsh_run 任务和常驻状态会保留。',
16
+ exceptions: '只读会话、明确的后台任务和需要宿主审批的有效提权请求,仍可使用原生 pwsh。',
17
+ on: '已开启', off: '已关闭', pending: '有尚未保存的修改', inherited: '恢复配置默认值',
18
+ form: { unavailable: '配置暂不可用,请确认插件已在当前主机加载。', readOnly: '当前部署的配置只读,无法保存修改。', saveFailed: '保存失败,请刷新配置后重试。', save: '保存', saving: '保存中…' },
19
+ },
20
+ en: {
21
+ title: 'pwsh settings', group: 'Tool routing', label: 'Require pwsh_run',
22
+ summary: 'Choose whether models must use persistent PowerShell.',
23
+ intro: 'Manage PowerShell routing for the current profile.',
24
+ hint: 'When enabled, ordinary pwsh calls are blocked before execution and the model is directed to pwsh_run.',
25
+ scope: 'Applies to every preset and session in this profile. Saving takes effect immediately and preserves running pwsh_run tasks and session state.',
26
+ exceptions: 'Read-only sessions, explicit background jobs, and valid elevation requests that require Host approval retain native pwsh.',
27
+ on: 'Enabled', off: 'Disabled', pending: 'Unsaved changes', inherited: 'Restore configuration default',
28
+ form: { unavailable: 'Configuration unavailable. Check that the plugin is loaded on this Host.', readOnly: 'Configuration is read-only in this deployment.', saveFailed: 'Not saved. Refresh the configuration and try again.', save: 'Save', saving: 'Saving…' },
29
+ },
30
+ }
31
+ const copy = () => (document.documentElement.lang || navigator.language || 'en').toLowerCase().startsWith('zh') ? COPY.zh : COPY.en
32
+ const style = {
33
+ page: { display: 'grid', gap: 24, width: '100%', maxWidth: 680, padding: '8px 0' },
34
+ title: { margin: 0, fontSize: 21, fontWeight: 600, lineHeight: 1.4 },
35
+ intro: { margin: '8px 0 0', fontSize: 13, opacity: 0.65, lineHeight: 1.65 },
36
+ section: { display: 'grid', gap: 16 },
37
+ group: { margin: 0, fontSize: 12, fontWeight: 600, opacity: 0.65 },
38
+ row: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 24 },
39
+ field: { display: 'grid', gap: 6 },
40
+ label: { fontSize: 14, fontWeight: 500 },
41
+ note: { margin: 0, fontSize: 13, lineHeight: 1.7, opacity: 0.65 },
42
+ status: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 12, fontSize: 12, opacity: 0.65 },
43
+ reset: { marginLeft: 'auto' },
44
+ }
45
+ function SettingsPage(props) {
46
+ const t = copy()
47
+ if (props.view === 'summary') return t.summary
48
+ const state = props.usePwshSettings((value) => value)
49
+ const disabled = !state.writable || state.saving
50
+ return h('div', { style: style.page, 'data-pwsh-settings': '' },
51
+ h('header', null, h('h2', { style: style.title }, t.title), h('p', { style: style.intro }, t.intro)),
52
+ h(SettingsForm, { labels: t.form, state, onSave: props.save, onDiscard: props.discard },
53
+ h('section', { style: style.section },
54
+ h('h3', { style: style.group }, t.group),
55
+ h('div', { style: style.row },
56
+ h('div', { style: style.field }, h('span', { style: style.label }, t.label), h('p', { style: style.note }, t.hint)),
57
+ h(Switch, { label: t.label, checked: state.field.text === 'true', disabled, onChange: (value) => props.edit(FIELD, value ? 'true' : 'false') })),
58
+ h('div', { style: style.status, role: 'status' },
59
+ h('span', null, state.accepted ? t.on : t.off),
60
+ state.dirty ? h('span', null, t.pending) : null,
61
+ state.field.overridden ? h(Button, { style: style.reset, variant: 'ghost', size: 'sm', disabled, onClick: () => props.resetField(FIELD) }, t.inherited) : null),
62
+ h('p', { style: style.note }, t.scope),
63
+ h('p', { style: style.note }, t.exceptions))))
64
+ }
65
+ function apply(ctx) {
66
+ const scope = ctx.configForms.get(NS)
67
+ const form = new SettingsFormModel(scope, [{
68
+ field: FIELD,
69
+ format: (value) => value === true ? 'true' : 'false',
70
+ parse: (text) => text === 'true' || text === 'false' ? { kind: 'set', value: text === 'true' } : undefined,
71
+ }])
72
+ const store = form.bind(() => ({ ...form.shell(), field: form.field(FIELD), accepted: scope.getSnapshot().value?.[FIELD] === true }))
73
+ const inject = () => ({ hooks: { pwshSettings: store }, ...form.actions() })
74
+ ctx.effect(() => () => form.dispose(), 'pwsh-run.form')
75
+ ctx.effect(() => {
76
+ let dispose = () => {}
77
+ const register = () => {
78
+ dispose()
79
+ dispose = ctx.configForms.whileServed([NS], () => {
80
+ const section = ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'pwsh-run', order: 43, label: copy().title, inject }, SettingsPage))
81
+ const row = ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-pwsh-plugin#pwsh-run', inject }, SettingsPage))
82
+ return () => { row(); section() }
83
+ })
84
+ }
85
+ register()
86
+ // 导航文字由注册项持有,语言改变时重新登记,让宿主刷新入口标题。
87
+ const observer = new MutationObserver(register)
88
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
89
+ return () => { observer.disconnect(); dispose() }
90
+ }, 'pwsh-run.settings-page')
91
+ }
92
+ return { name: 'pwsh-run-settings', inject: ['slots', 'configForms'], apply }
93
+ },
94
+ })
package/cordis.patch.yml CHANGED
@@ -13,6 +13,7 @@
13
13
  timeoutMs: 120000
14
14
  idleMs: 1800000
15
15
  maxSessions: 4
16
+ enforcePwshRun: false
16
17
 
17
18
  # Windows 上的 bash:官方 persistent bash 依赖 PTY,PTY 后端只支持 linux/darwin,
18
19
  # 而官方 bash 工具在 win32 上被禁用,Windows 会话因此没有 bash。这一行补上它,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pwsh-plugin",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
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",
@@ -24,10 +24,12 @@
24
24
  "./cordis.patch.yml": "./cordis.patch.yml",
25
25
  "./locale/*.json": "./locale/*.json",
26
26
  "./icon": "./icon.svg",
27
- "./package.json": "./package.json"
27
+ "./package.json": "./package.json",
28
+ "./client": "./client/index.js"
28
29
  },
29
30
  "files": [
30
31
  "plugin",
32
+ "client",
31
33
  "wsl",
32
34
  "docs",
33
35
  "locale",
@@ -42,6 +44,12 @@
42
44
  "dsh": {
43
45
  "bundle": {
44
46
  "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "platform": "web",
50
+ "inject": [
51
+ "@deepseek-ai/dsh-client-ui-settings"
52
+ ]
45
53
  }
46
54
  },
47
55
  "engines": {
@@ -51,7 +59,17 @@
51
59
  "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0"
52
60
  },
53
61
  "scripts": {
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"
62
+ "test": "node test/settingstest.mjs && node test/routingtest.mjs && node test/protocoltest.mjs && node test/lifecycletest.mjs && node test/itest.mjs && node test/plugintest.mjs && node test/denialtest.mjs && node test/metatest.mjs",
63
+ "test:all": "node test/settingstest.mjs && node test/routingtest.mjs && 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"
64
+ },
65
+ "dependencies": {
66
+ "@deepseek-ai/schemastery": "^3.19.0"
67
+ },
68
+ "devDependencies": {
69
+ "clsx": "2.1.1",
70
+ "immer": "10.1.1",
71
+ "react": "18.3.1",
72
+ "react-dom": "18.3.1",
73
+ "zustand": "4.5.7"
56
74
  }
57
75
  }
@@ -0,0 +1,13 @@
1
+ import Schema from '@deepseek-ai/schemastery'
2
+ import { DEFAULT_MAX_OUTPUT_BYTES, MAX_TIMEOUT_MS } from './session.mjs'
3
+
4
+ // 只有路由开关热更新;进程参数改变仍交给 Loader 重挂实例。
5
+ export const Config = Schema.object({
6
+ pwshPath: Schema.string().default('pwsh'),
7
+ timeoutMs: Schema.number().min(1).max(MAX_TIMEOUT_MS).step(1).default(120000),
8
+ idleMs: Schema.number().min(1).step(1).default(1800000),
9
+ maxSessions: Schema.number().min(1).step(1).default(4),
10
+ maxOutputBytes: Schema.number().min(1).max(2147483647).step(1).default(DEFAULT_MAX_OUTPUT_BYTES),
11
+ allowUnconfined: Schema.boolean().default(false),
12
+ enforcePwshRun: Schema.boolean().default(false).volatile(),
13
+ })
package/plugin/index.mjs CHANGED
@@ -29,6 +29,8 @@
29
29
  */
30
30
 
31
31
  import { readFileSync } from 'node:fs'
32
+ export { Config } from './config.mjs'
33
+ import { installNativePwshGuard } from './native-pwsh-guard.mjs'
32
34
  import { DEFAULT_MAX_OUTPUT_BYTES, MAX_TIMEOUT_MS, bootArgv, startSession, validateTimeout, abortable } from './session.mjs'
33
35
 
34
36
  /** 加载器诊断里显示的插件名。 */
@@ -145,6 +147,12 @@ export function apply(ctx, config) {
145
147
  return sandboxPolicy.resolve(session === undefined ? {} : { session })
146
148
  }
147
149
 
150
+ installNativePwshGuard(ctx, config?.enforcePwshRun, resolvePolicy)
151
+ // 页面复用 settings 服务的持久化与 revision 校验,不创建额外的配置文件。
152
+ ctx.inject?.(['settings'], (child) => {
153
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber), 'pwsh-run.settings-page')
154
+ })
155
+
148
156
  // 槽位在第一次 await 前登记;同 ID 的创建、边界切换与执行共用队列。
149
157
  const sessions = new Map()
150
158
  let stopped = false
@@ -0,0 +1,43 @@
1
+ const MODE_RANK = new Map([
2
+ ['read-only', 0], ['workspace-write', 1], ['danger-full-access', 2],
3
+ ])
4
+
5
+ const DENIAL = 'Ordinary native pwsh calls are disabled by config.enforcePwshRun. '
6
+ + 'Use pwsh_run instead: pass the PowerShell command as script, and keep workdir and timeoutMs when supplied. '
7
+ + 'The native command was not executed.'
8
+
9
+ /** 守卫在宿主执行前策略之后检查;不用可被后续监听器改回 allow 的瀑布钩子。 */
10
+ export function installNativePwshGuard(ctx, enabled, resolvePolicy) {
11
+ const live = enabled !== null && typeof enabled === 'object' && typeof enabled.get === 'function'
12
+ const readEnabled = () => {
13
+ const value = live ? enabled.get() : enabled
14
+ if (value !== undefined && typeof value !== 'boolean') {
15
+ throw new Error('pwsh_run enforcePwshRun must be a boolean')
16
+ }
17
+ return value === true
18
+ }
19
+ const initial = readEnabled()
20
+ if (!live && !initial) return
21
+ if (typeof ctx.tools.guard !== 'function') {
22
+ throw new Error('pwsh_run enforcePwshRun requires a DSH host with tools.guard(); '
23
+ + 'upgrade DSH to use this routing configuration. No routing guard was installed.')
24
+ }
25
+
26
+ ctx.effect(() => ctx.tools.guard((exec) => {
27
+ // Loader 原地更新 volatile 引用;读取它不会销毁正在使用的常驻进程。
28
+ if (exec.name !== 'pwsh' || !readEnabled()) return undefined
29
+ // 后台 job 的句柄与逐次审批由原生工具持有,常驻会话不能代接。
30
+ const args = exec.arguments
31
+ if (args?.run_in_background === true) return undefined
32
+ const mode = resolvePolicy(exec.agent)?.mode
33
+ if (mode === 'read-only') return undefined
34
+ const requestedRank = MODE_RANK.get(args?.sandbox_permissions)
35
+ const currentRank = MODE_RANK.get(mode)
36
+ if (requestedRank !== undefined && currentRank !== undefined && requestedRank > currentRank
37
+ && typeof args?.justification === 'string' && args.justification.trim().length > 0) {
38
+ // 只放行真实的提权请求;是否批准仍由原生工具的审批机制决定。
39
+ return undefined
40
+ }
41
+ return DENIAL
42
+ }), 'pwsh-run.native-guard')
43
+ }