dsh-wsl-tool 1.9.1 → 1.10.1

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/PUBLISHING.md CHANGED
@@ -67,6 +67,22 @@ both that the entry stays relative and that it resolves.
67
67
  once at load. A patch change (the tool row, or the sidebar terminal override)
68
68
  is read at composition time, so it needs that restart too.
69
69
 
70
+ **Then prove the schema really builds from the installed copy**, because this
71
+ is the one failure the tools survive:
72
+
73
+ ```sh
74
+ cd ~/.dsh/profiles/desktop/node_modules/dsh-wsl
75
+ node -e "import('./index.js').then(m => console.log(typeof m.Config, JSON.stringify(m.Config({}))))"
76
+ ```
77
+
78
+ It must print `function` and the resolved defaults. If it prints `undefined`,
79
+ the plugin declares no `Config`, the platform has no schema to project, the
80
+ entry's config status stays `absent` and the sidebar panel renders without a
81
+ single switch while all three tools keep working. The fragile step is the
82
+ interop hop: `@deepseek-ai/schemastery` exports its builder as the **default**
83
+ export, so `const { Schema } = await import(...)` silently yields `undefined`
84
+ (see `lib/schema.js`, and the regression test that pins the picking).
85
+
70
86
  3. The tag runs the pipeline. The order is deliberate — the release asset goes
71
87
  **first** because it is the market's critical path, then npm, so a failing npm
72
88
  publish fails the run loudly without withholding the release.
package/README.md CHANGED
@@ -134,6 +134,38 @@ A background job is owned by the calling session (`owner: exec.agent.id`), which
134
134
  is what lets the model read it back with `job_output`/`job_kill` and what keeps
135
135
  other sessions out; an execution with no agent starts the job unowned.
136
136
 
137
+ ## The WSL panel (left sidebar)
138
+
139
+ The plugin ships a small client half: a **WSL** entry in the desktop app's left
140
+ sidebar, whose panel carries one switch per feature, each with a one-line
141
+ explanation.
142
+
143
+ | Switch | What it controls |
144
+ |---|---|
145
+ | `wsl` 命令执行 | registers the `wsl` tool |
146
+ | `wsl-path` 路径转换 | registers the `wsl-path` tool |
147
+ | `wsl-env` 能力体检 | registers the `wsl-env` tool |
148
+ | 后台任务 | whether `wsl` accepts `runInBackground` |
149
+ | 自动转换路径 | the default for the per-call `translatePaths` |
150
+ | 默认跟随会话工作区 | start in the session's directory instead of `~` when `workdir` is omitted |
151
+ | 危险命令守卫 | whether a destructive command needs an explicit `allowDangerous` |
152
+
153
+ The panel edits the plugin's own configuration, so the same values can be written
154
+ by hand (`- id: tool-wsl` with `config:` in a profile patch) or by environment
155
+ variables. `lib/config.js` owns the precedence — **plugin configuration, then the
156
+ environment, then the built-in defaults** — and a switch left at its default lets
157
+ the layer below decide, which is why `DSH_WSL_WORKDIR=session` keeps working for
158
+ someone who never opened the panel.
159
+
160
+ **A change takes effect at the next DSH start**: the host reads this configuration
161
+ once per mount, and the panel says so. Distro and timeout are values rather than
162
+ features — set them in the patch or with `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS`,
163
+ and the panel shows what is currently in effect.
164
+
165
+ The settings surface needs `@deepseek-ai/schemastery`, which the plugin declares as
166
+ an optional peer dependency: without it the three tools still run on their defaults
167
+ and only the panel is missing.
168
+
137
169
  ## Optional: a WSL terminal in the sidebar
138
170
 
139
171
  The desktop app's sidebar terminal can open WSL instead of a Windows shell. It is
package/README.zh-CN.md CHANGED
@@ -116,6 +116,31 @@ DSH_SUBPROCESS_LOCAL=/path/to/dsh/node_modules npm run test:real
116
116
  `job_output`/`job_kill` 读回它的依据,也是其他会话读不到它的围栏;exec 里没有 agent 时
117
117
  任务则是无主的。
118
118
 
119
+ ## 左侧栏的 WSL 面板
120
+
121
+ 插件带了一个小的客户端半:桌面版左侧栏里多一个 **WSL** 入口,面板里每个功能一个开关,每个开关
122
+ 配一行说明。
123
+
124
+ | 开关 | 管什么 |
125
+ |---|---|
126
+ | `wsl` 命令执行 | 是否注册 `wsl` 工具 |
127
+ | `wsl-path` 路径转换 | 是否注册 `wsl-path` 工具 |
128
+ | `wsl-env` 能力体检 | 是否注册 `wsl-env` 工具 |
129
+ | 后台任务 | `wsl` 是否接受 `runInBackground` |
130
+ | 自动转换路径 | 每次调用的 `translatePaths` 默认值 |
131
+ | 默认跟随会话工作区 | 未传 `workdir` 时从会话目录开始,而不是 `~` |
132
+ | 危险命令守卫 | 危险命令是否必须显式 `allowDangerous` |
133
+
134
+ 面板改的是插件自己的配置,所以同样的值也可以手写进 profile patch(`- id: tool-wsl` 加 `config:`)
135
+ 或用环境变量设。优先级由 `lib/config.js` 定:**插件配置 > 环境变量 > 内置默认值**;开关停在默认值时
136
+ 下层说了算 —— 这正是"从没打开过面板的人,`DSH_WSL_WORKDIR=session` 依然生效"的原因。
137
+
138
+ **改动在下次启动 DSH 后生效**:宿主每次挂载只读一次该配置,面板里也写着这句。发行版与超时属于"值"
139
+ 而不是"功能":在 patch 里或用 `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS` 设置,面板只显示当前生效值。
140
+
141
+ 这个设置界面需要 `@deepseek-ai/schemastery`(插件把它声明为可选 peer 依赖):没有它三个工具照常按
142
+ 默认值工作,只是没有面板。
143
+
119
144
  ## 可选:在侧边栏开一个 WSL 终端
120
145
 
121
146
  桌面版侧边栏终端可以开 WSL 而不是 Windows shell。这是**可选**的 —— 装插件**不会**改你终端默认
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // dsh-wsl: model-facing WSL tools for DeepSeek Harness (DSH).
2
2
  //
3
- // Registers three tools:
3
+ // Registers up to three tools, each one switchable from the plugin's
4
+ // left-sidebar panel:
4
5
  // - `wsl` : run a Linux command through wsl.exe, returning stdout/stderr
5
6
  // with exit-code / signal / timeout / truncation markers.
6
7
  // - `wsl-path` : convert between Windows and WSL paths via `wslpath`.
@@ -11,12 +12,17 @@
11
12
  // plugin publishes nothing and only consumes the host-plane `subprocess` and
12
13
  // `tools` registries, so it sits loose in an agent preset without a realm.
13
14
  //
14
- // The implementation lives in `lib/`: `config` (defaults and environment
15
- // overrides), `paths` (path translation and shell quoting), `guard` (the
16
- // destructive-command rules), `result` (markers and truncation), `runner` (the
17
- // one spawn path) and `tools/` (the three tool definitions).
15
+ // The implementation lives in `lib/`: `config` (defaults, environment overrides
16
+ // and the plugin's own switches), `paths` (path translation and shell quoting),
17
+ // `guard` (the destructive-command rules), `result` (markers and truncation),
18
+ // `runner` (the one spawn path), `tools/` (the three tool definitions) and
19
+ // `client.js` (the sidebar panel, a separate web-platform bundle).
20
+ //
21
+ // A configuration change is a mount, not something a running session re-reads:
22
+ // every switch below is consulted once, in `apply`.
18
23
 
19
24
  import { resolveConfig } from './lib/config.js'
25
+ import { pickSchemaBuilder } from './lib/schema.js'
20
26
  import { createRunner } from './lib/runner.js'
21
27
  import { createWslTool } from './lib/tools/wsl.js'
22
28
  import { createWslPathTool } from './lib/tools/wsl-path.js'
@@ -25,15 +31,65 @@ import { createWslEnvTool } from './lib/tools/wsl-env.js'
25
31
  export const name = 'tool-wsl'
26
32
  export const inject = ['tools', 'subprocess']
27
33
 
28
- export function apply(ctx) {
29
- // Read the environment once per mount: a configuration change is a mount
30
- // (i.e. a DSH restart), not something a running session re-reads.
31
- const config = resolveConfig()
34
+ // The schema is what gives this plugin a settings surface at all: the platform
35
+ // projects the config of every entry that declares one (`ctx.settings.describe()`
36
+ // is keyed by entry id), and the sidebar panel reads and writes that projection.
37
+ // It needs `@deepseek-ai/schemastery`, which a real DSH profile supplies (the
38
+ // plugin declares it as an optional peer dependency) but which is absent when this
39
+ // module is imported standalone, as `test/smoke.mjs` does. A soft import keeps the
40
+ // tools runnable there: without a schema every switch keeps its default and only
41
+ // the panel is missing — and `lib/schema.js` explains why the PICKING, not just
42
+ // the import, is the fragile part.
43
+ let Schema = null
44
+ try {
45
+ Schema = pickSchemaBuilder(await import('@deepseek-ai/schemastery'))
46
+ } catch {
47
+ Schema = null
48
+ }
49
+ if (Schema === null) {
50
+ // Not fatal — the tools run on their defaults — but it is exactly the difference
51
+ // between a panel with switches and one without, so say so instead of failing
52
+ // quietly.
53
+ console.warn(
54
+ 'dsh-wsl-tool: no schema builder (@deepseek-ai/schemastery) — the tools run on ' +
55
+ 'their defaults and the sidebar panel will not offer the switches',
56
+ )
57
+ }
58
+
59
+ /**
60
+ * The plugin's own configuration.
61
+ *
62
+ * Every field is ALSO settable by hand in a profile patch
63
+ * (`- id: tool-wsl` / `config:`) and by the environment, and `lib/config.js`
64
+ * documents the precedence: this configuration wins, the environment is the
65
+ * deployment default, the built-in defaults are last.
66
+ *
67
+ * `distro` and `timeoutMs` are deliberately absent from the sidebar panel (they
68
+ * are values, not features) but stay here so a patch or a panel could grow a
69
+ * field for them without a schema change.
70
+ */
71
+ export const Config = Schema?.object({
72
+ tools: Schema.object({
73
+ wsl: Schema.boolean().default(true).description('注册 `wsl` 工具:在 WSL 里执行 Linux 命令。'),
74
+ path: Schema.boolean().default(true).description('注册 `wsl-path` 工具:Windows 路径与 /mnt/... 互转。'),
75
+ env: Schema.boolean().default(true).description('注册 `wsl-env` 工具:汇总 WSL 环境能力。'),
76
+ }).description('要注册哪几个工具。'),
77
+ backgroundJobs: Schema.boolean().default(true).description('允许 `runInBackground`,由内置 job 工具读回结果。'),
78
+ translatePaths: Schema.boolean().default(true).description('默认把命令里的 Windows 路径转成 /mnt/...。'),
79
+ startInSessionWorkspace: Schema.boolean().default(false).description('未传 `workdir` 时从会话工作区开始,而不是 Linux 家目录。'),
80
+ dangerGuard: Schema.boolean().default(true).description('危险命令必须显式 `allowDangerous` 才放行。关掉后模型可直接删除/分区。'),
81
+ distro: Schema.string().default('').description('要固定使用的发行版;留空则用系统默认(也可用 DSH_WSL_DISTRO)。'),
82
+ timeoutMs: Schema.number().default(0).description('默认命令超时毫秒数;0 表示用内置默认(也可用 DSH_WSL_TIMEOUT_MS)。'),
83
+ }).description('dsh-wsl 的功能开关与默认值。')
84
+
85
+ export function apply(ctx, settings = {}) {
86
+ // Resolved once per mount: a settings change is a restart, not a live edit.
87
+ const config = resolveConfig(process.env, settings)
32
88
  const runner = createRunner(ctx, config)
33
- // `ctx` is passed for the optional `jobs` service only (background commands);
34
- // it is read with ctx.get at call time, never injected, so a preset without
35
- // tool-jobs still mounts this plugin.
36
- ctx.tools.register(createWslTool({ ctx, config, runner }))
37
- ctx.tools.register(createWslPathTool({ config, runner }))
38
- ctx.tools.register(createWslEnvTool({ config, runner }))
89
+ // `ctx` is passed to the `wsl` tool for the optional `jobs` service only
90
+ // (background commands); it is read with ctx.get at call time, never injected,
91
+ // so a preset without tool-jobs still mounts this plugin.
92
+ if (config.tools.wsl) ctx.tools.register(createWslTool({ ctx, config, runner }))
93
+ if (config.tools.path) ctx.tools.register(createWslPathTool({ config, runner }))
94
+ if (config.tools.env) ctx.tools.register(createWslEnvTool({ config, runner }))
39
95
  }
package/lib/client.js ADDED
@@ -0,0 +1,691 @@
1
+ // dsh-wsl-tool, browser half.
2
+ //
3
+ // This file is a PLAIN hand-written bundle, exactly like the ones DSH ships: no
4
+ // build step, no `import`/`export` keywords, no JSX, no TypeScript. The page
5
+ // loads it as a script, the module loader below hands it a `require`, and the
6
+ // only two modules it may ask for are React and the client UI primitives.
7
+ //
8
+ // What it contributes:
9
+ // - one rail entry in the left sidebar (`sidebar.panellist`, a list seat), and
10
+ // - the panel behind that entry (`main`, a keyed seat under the same id),
11
+ // whose switches edit this plugin's own Host configuration row.
12
+ //
13
+ // The Host half reads its settings ONCE per mount (see `lib/config.js`), so a
14
+ // switch here changes the documented file/profile configuration; it does not
15
+ // reconfigure a running session. The panel says so in one line instead of
16
+ // pretending otherwise.
17
+ //
18
+ // Layout facts this file depends on (all verified against the shipped client):
19
+ // - `sidebar.panellist` entries carry `id`, `order` and `label`, and render
20
+ // their own icon (there is no icon field).
21
+ // - `main` is keyed by that same id, and its `inject` callback supplies extra
22
+ // props to the panel component.
23
+ // - `ctx.configForms.get(rowId)` returns a scope with
24
+ // `getSnapshot()` / `subscribe(listener)` / `mutate(ops, revision)`, where a
25
+ // snapshot is `{ status, value, base, user, writable, revision }` and one op
26
+ // is `{ op: 'set', path, value }` or `{ op: 'unset', path }`.
27
+
28
+ window.__ModuleLoader__.load({
29
+ id: 'dsh-wsl-tool',
30
+ factory: (require) => {
31
+ var module = { exports: {} }
32
+ var exports = module.exports
33
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
34
+
35
+ const React = require('react')
36
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
37
+
38
+ /**
39
+ * Panel identity. The layout pairs one `sidebar.panellist` entry with the
40
+ * `main` entry registered under the same key, so these two must agree; the
41
+ * rail entry's `label` is the visible text, the accessible name and the
42
+ * collapsed tooltip.
43
+ */
44
+ const PANEL_ID = 'dsh-wsl'
45
+
46
+ /**
47
+ * Loader row id of this bundle's Host half, read from our own
48
+ * `cordis.patch.yml`. `configForms.get` addresses a Host configuration entry
49
+ * by that row id — not by the package name, because one package may be
50
+ * composed as several rows under different ids.
51
+ */
52
+ const CONFIG_ROW_ID = 'tool-wsl'
53
+
54
+ // Theme tokens, read as CSS custom properties so the panel follows the
55
+ // active light/dark theme without importing any stylesheet. Each carries a
56
+ // neutral fallback: an unknown token must not make text invisible.
57
+ const LABEL_PRIMARY = 'var(--dsw-alias-label-primary, inherit)'
58
+ const LABEL_SECONDARY = 'var(--dsw-alias-label-secondary, inherit)'
59
+ const LABEL_TERTIARY = 'var(--dsw-alias-label-tertiary, inherit)'
60
+ const BORDER_SOFT = 'var(--dsw-alias-border-l2, rgba(127, 127, 127, 0.25))'
61
+ const BORDER_BADGE = 'var(--dsw-alias-border-l3, rgba(127, 127, 127, 0.35))'
62
+ const WARN_LABEL = 'var(--dsw-alias-state-warn-label, #b45309)'
63
+ const LINK_LABEL = 'var(--dsw-alias-link, inherit)'
64
+ const CODE_FILL = 'var(--dsw-alias-markdown-code-block, rgba(127, 127, 127, 0.12))'
65
+ const CODE_LABEL = 'var(--dsw-alias-markdown-inline-code, inherit)'
66
+ const RADIUS = 'var(--dsw-radius-xs, 4px)'
67
+
68
+ /**
69
+ * The switches, grouped into the two sections the panel renders.
70
+ *
71
+ * `path` - path inside this plugin's configuration object (settings ops are
72
+ * path-addressed, so a nested section is one array, not a string).
73
+ * `label` - visible row text; also the switch's accessible name.
74
+ * `hint` - the one-line explanation under the label.
75
+ * `when` - the composed default for the row, used ONLY when the resolved
76
+ * value does not carry the field at all (an older Host half, or a
77
+ * field the Host schema does not declare). Reading a missing field
78
+ * as `false` would silently mislabel a default-on feature.
79
+ * `danger` - marks a switch whose off state widens what the model may run.
80
+ */
81
+ const SECTIONS = [
82
+ {
83
+ id: 'tools',
84
+ title: '工具',
85
+ rows: [
86
+ {
87
+ path: ['tools', 'wsl'],
88
+ label: 'wsl 命令执行',
89
+ hint: '注册 `wsl` 工具:让模型在 WSL 发行版里执行 Linux 命令(一次一个全新 shell)',
90
+ when: true,
91
+ },
92
+ {
93
+ path: ['tools', 'path'],
94
+ label: 'wsl-path 路径转换',
95
+ hint: '注册 `wsl-path` 工具:在 Windows 路径与 `/mnt/...` 之间互转',
96
+ when: true,
97
+ },
98
+ {
99
+ path: ['tools', 'env'],
100
+ label: 'wsl-env 能力体检',
101
+ hint: '注册 `wsl-env` 工具:汇总发行版、内核、systemd、cgroup、GPU 直通、docker、挂载盘与两侧配置',
102
+ when: true,
103
+ },
104
+ ],
105
+ },
106
+ {
107
+ id: 'behavior',
108
+ title: '行为',
109
+ rows: [
110
+ {
111
+ path: ['backgroundJobs'],
112
+ label: '后台任务',
113
+ hint: '允许长任务用 `runInBackground` 后台执行,再由内置 job 工具读回结果',
114
+ when: true,
115
+ },
116
+ {
117
+ path: ['translatePaths'],
118
+ label: '自动转换路径',
119
+ hint: '命令里的 Windows 路径自动转成 `/mnt/...`(每次调用仍可用 `translatePaths` 覆盖)',
120
+ when: true,
121
+ },
122
+ {
123
+ path: ['startInSessionWorkspace'],
124
+ label: '默认跟随会话工作区',
125
+ hint: '未传 `workdir` 时从会话所在目录开始(关掉则从 Linux 家目录 `~` 开始)',
126
+ when: false,
127
+ },
128
+ {
129
+ path: ['dangerGuard'],
130
+ label: '危险命令守卫',
131
+ hint: '删除、分区、关机等命令必须显式 `allowDangerous` 才放行。关掉后模型可直接执行这类命令,请谨慎',
132
+ when: true,
133
+ danger: true,
134
+ },
135
+ ],
136
+ },
137
+ ]
138
+
139
+ /**
140
+ * The sidebar-terminal snippet, verbatim.
141
+ *
142
+ * It is a String.raw template AND its lines start at column 0 on purpose: it
143
+ * must come out byte-for-byte as the YAML a user pastes into a profile patch,
144
+ * backslashes in the Windows path included. Re-indenting it or writing it with
145
+ * ordinary escapes would quietly corrupt the path.
146
+ */
147
+ const TERMINAL_PATCH_YAML = String.raw`- id: terminal-controller
148
+ config:
149
+ shell:
150
+ path: 'C:\Windows\System32\wsl.exe'
151
+ name: WSL
152
+ args: ['-e', 'bash', '-l']`
153
+
154
+ /** A setting nobody can read yet; shaped like a scope snapshot so the panel
155
+ * can treat it uniformly. `status: 'unavailable'` is the platform's own word
156
+ * for "this Host does not serve that configuration entry". */
157
+ const UNAVAILABLE_SNAPSHOT = {
158
+ status: 'unavailable',
159
+ value: undefined,
160
+ base: undefined,
161
+ user: undefined,
162
+ writable: false,
163
+ revision: undefined,
164
+ }
165
+
166
+ // ---------------------------------------------------------------- helpers
167
+
168
+ /** Read one path out of a possibly absent object, never throwing. */
169
+ function readPath(target, path) {
170
+ let cursor = target
171
+ for (const step of path) {
172
+ if (cursor === null || typeof cursor !== 'object') return undefined
173
+ cursor = cursor[step]
174
+ }
175
+ return cursor
176
+ }
177
+
178
+ /**
179
+ * Whether the USER layer itself carries this path — i.e. whether the value is
180
+ * an override rather than inherited from the composition or the schema
181
+ * default. Presence is what marks an override, not a value comparison: an
182
+ * override that happens to equal the default is still an override.
183
+ */
184
+ function hasPath(target, path) {
185
+ let cursor = target
186
+ for (const step of path) {
187
+ if (cursor === null || typeof cursor !== 'object') return false
188
+ if (!Object.prototype.hasOwnProperty.call(cursor, step)) return false
189
+ cursor = cursor[step]
190
+ }
191
+ return true
192
+ }
193
+
194
+ /**
195
+ * Everything this panel knows about the Host configuration.
196
+ *
197
+ * Both the service and the row are optional, so every read is tolerant:
198
+ * a missing `configForms`, a row this Host does not serve, and a scope whose
199
+ * `getSnapshot` throws all end at `unavailable` instead of an exception in
200
+ * the middle of a render.
201
+ */
202
+ function createConfigModel() {
203
+ let served = false
204
+ let scope
205
+ let unsubscribe
206
+ const listeners = new Set()
207
+
208
+ const publish = () => {
209
+ // Copy first: a listener may unsubscribe itself while being notified.
210
+ for (const listener of Array.from(listeners)) {
211
+ try {
212
+ listener()
213
+ } catch (error) {
214
+ console.error('dsh-wsl-tool: a settings panel listener failed', error)
215
+ }
216
+ }
217
+ }
218
+
219
+ const read = () => {
220
+ if (scope === undefined || scope === null || typeof scope.getSnapshot !== 'function') {
221
+ return UNAVAILABLE_SNAPSHOT
222
+ }
223
+ try {
224
+ const snapshot = scope.getSnapshot()
225
+ return snapshot !== null && typeof snapshot === 'object' ? snapshot : UNAVAILABLE_SNAPSHOT
226
+ } catch (error) {
227
+ console.error('dsh-wsl-tool: reading the configuration scope failed', error)
228
+ return UNAVAILABLE_SNAPSHOT
229
+ }
230
+ }
231
+
232
+ return {
233
+ /** Whether the `configForms` service was there at all. A service this DSH
234
+ * does not ship reads differently from a row it does not serve. */
235
+ hasService: () => served,
236
+ /** Bind the scope once the service appears. */
237
+ attach: (next) => {
238
+ if (next === undefined || next === null) return
239
+ served = true
240
+ scope = next
241
+ if (typeof scope.subscribe === 'function') {
242
+ try {
243
+ unsubscribe = scope.subscribe(publish)
244
+ } catch (error) {
245
+ console.error('dsh-wsl-tool: subscribing to the configuration scope failed', error)
246
+ }
247
+ }
248
+ publish()
249
+ },
250
+ /** Release the scope subscription; the caller's effect owns the call. */
251
+ detach: () => {
252
+ if (typeof unsubscribe === 'function') {
253
+ try {
254
+ unsubscribe()
255
+ } catch (error) {
256
+ console.error('dsh-wsl-tool: releasing the configuration scope failed', error)
257
+ }
258
+ }
259
+ unsubscribe = undefined
260
+ },
261
+ read,
262
+ subscribe: (listener) => {
263
+ listeners.add(listener)
264
+ return () => {
265
+ listeners.delete(listener)
266
+ }
267
+ },
268
+ /**
269
+ * Submit exactly one path-addressed write against the revision read just
270
+ * now, and answer whether the Host accepted it.
271
+ *
272
+ * The revision matters: the settings service refuses a write that was
273
+ * composed against a value somebody else has already replaced, rather than
274
+ * silently overwriting it. Its own settings form treats any falsy answer as
275
+ * a refusal, so this panel does the same — `false` means "not saved", and
276
+ * the panel words it instead of pretending the switch moved.
277
+ */
278
+ write: async (op) => {
279
+ const snapshot = read()
280
+ if (scope === undefined || typeof scope.mutate !== 'function') return false
281
+ if (snapshot.writable !== true) return false
282
+ try {
283
+ const landed = await scope.mutate([op], snapshot.revision)
284
+ return Boolean(landed)
285
+ } catch (error) {
286
+ console.error('dsh-wsl-tool: writing the configuration failed', error)
287
+ return false
288
+ }
289
+ },
290
+ }
291
+ }
292
+
293
+ /** Stand-in model for a seat that somehow renders without injected props, so
294
+ * a missing prop shows the "not served" note instead of throwing. */
295
+ const DETACHED_MODEL = createConfigModel()
296
+
297
+ // ----------------------------------------------------------------- styles
298
+
299
+ const PANEL_STYLE = {
300
+ boxSizing: 'border-box',
301
+ display: 'flex',
302
+ flexDirection: 'column',
303
+ gap: '24px',
304
+ height: '100%',
305
+ maxWidth: '760px',
306
+ overflowY: 'auto',
307
+ padding: '24px',
308
+ color: LABEL_PRIMARY,
309
+ fontFamily: 'inherit',
310
+ fontSize: '13px',
311
+ lineHeight: '20px',
312
+ }
313
+ const HEADER_STYLE = { display: 'flex', flexDirection: 'column', gap: '4px' }
314
+ const TITLE_STYLE = { margin: 0, fontSize: '18px', fontWeight: 600, lineHeight: '26px' }
315
+ const DESCRIPTION_STYLE = { margin: 0, color: LABEL_SECONDARY }
316
+ const NOTE_STYLE = { margin: 0, color: LABEL_TERTIARY, fontSize: '12px', lineHeight: '18px' }
317
+ const STACK_STYLE = { display: 'flex', flexDirection: 'column', gap: '24px' }
318
+ const SECTION_STYLE = { display: 'flex', flexDirection: 'column', gap: '4px' }
319
+ const SECTION_TITLE_STYLE = { margin: 0, fontSize: '14px', fontWeight: 500, lineHeight: '20px' }
320
+ const ROWS_STYLE = { display: 'flex', flexDirection: 'column' }
321
+ const ROW_STYLE = {
322
+ display: 'flex',
323
+ alignItems: 'flex-start',
324
+ justifyContent: 'space-between',
325
+ gap: '16px',
326
+ padding: '14px 0',
327
+ borderBottom: '0.5px solid ' + BORDER_SOFT,
328
+ }
329
+ const ROW_COPY_STYLE = { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 }
330
+ const ROW_LABEL_STYLE = { display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 500 }
331
+ const ROW_ACTIONS_STYLE = { display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }
332
+ const HINT_STYLE = { margin: 0, color: LABEL_TERTIARY, fontSize: '12px', lineHeight: '18px', maxWidth: '66ch' }
333
+ const BADGE_STYLE = {
334
+ border: '0.5px solid ' + BORDER_BADGE,
335
+ borderRadius: RADIUS,
336
+ color: LABEL_SECONDARY,
337
+ fontSize: '11px',
338
+ lineHeight: '16px',
339
+ padding: '1px 6px',
340
+ whiteSpace: 'nowrap',
341
+ }
342
+ const DANGER_STYLE = {
343
+ border: '0.5px solid currentColor',
344
+ borderRadius: RADIUS,
345
+ color: WARN_LABEL,
346
+ fontSize: '11px',
347
+ lineHeight: '16px',
348
+ padding: '0 4px',
349
+ whiteSpace: 'nowrap',
350
+ }
351
+ const RESET_STYLE = {
352
+ background: 'none',
353
+ border: 'none',
354
+ color: LINK_LABEL,
355
+ cursor: 'pointer',
356
+ fontFamily: 'inherit',
357
+ fontSize: '12px',
358
+ fontWeight: 'inherit',
359
+ padding: 0,
360
+ textDecoration: 'underline',
361
+ }
362
+ const DEFINITION_ROW_STYLE = { display: 'flex', alignItems: 'baseline', gap: '12px' }
363
+ const DEFINITION_LABEL_STYLE = { color: LABEL_SECONDARY, minWidth: '13em' }
364
+ const DEFINITION_VALUE_STYLE = { color: LABEL_PRIMARY }
365
+ const PRE_STYLE = {
366
+ margin: 0,
367
+ padding: '12px',
368
+ background: CODE_FILL,
369
+ borderRadius: RADIUS,
370
+ color: CODE_LABEL,
371
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
372
+ fontSize: '12px',
373
+ lineHeight: '18px',
374
+ overflowX: 'auto',
375
+ whiteSpace: 'pre',
376
+ }
377
+
378
+ // ------------------------------------------------------------ components
379
+
380
+ /**
381
+ * The rail icon. A `sidebar.panellist` entry draws its own glyph: an 18×18
382
+ * terminal prompt in `currentColor`, so the layout's selected/hover colours
383
+ * come through untouched.
384
+ */
385
+ function WslIcon() {
386
+ return React.createElement(
387
+ 'svg',
388
+ {
389
+ width: 18,
390
+ height: 18,
391
+ viewBox: '0 0 20 20',
392
+ fill: 'none',
393
+ stroke: 'currentColor',
394
+ strokeWidth: 1.5,
395
+ strokeLinecap: 'round',
396
+ strokeLinejoin: 'round',
397
+ 'aria-hidden': 'true',
398
+ focusable: 'false',
399
+ },
400
+ // The terminal frame, the prompt chevron, and the command line.
401
+ React.createElement('path', {
402
+ d: 'M3.5 4.5h13a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1Z',
403
+ }),
404
+ React.createElement('path', { d: 'M6.5 8.5 8.5 10.5 6.5 12.5' }),
405
+ React.createElement('path', { d: 'M10.5 13h3' }),
406
+ )
407
+ }
408
+
409
+ /** One read-only label/value line, used by the effective-defaults section. */
410
+ function definitionRow(label, value) {
411
+ return React.createElement(
412
+ 'div',
413
+ { style: DEFINITION_ROW_STYLE },
414
+ React.createElement('span', { style: DEFINITION_LABEL_STYLE }, label),
415
+ React.createElement('span', { style: DEFINITION_VALUE_STYLE }, value),
416
+ )
417
+ }
418
+
419
+ /** A section frame: a heading plus its body, which may be several nodes. */
420
+ function section(key, title, ...children) {
421
+ return React.createElement(
422
+ 'section',
423
+ { key, style: SECTION_STYLE },
424
+ React.createElement('h3', { style: SECTION_TITLE_STYLE }, title),
425
+ ...children,
426
+ )
427
+ }
428
+
429
+ /**
430
+ * The panel behind the rail entry.
431
+ *
432
+ * It renders whatever the scope currently says — `ready`, still `loading`, or
433
+ * `unavailable` — because a settings panel that throws or renders nothing on a
434
+ * stripped composition is worse than one that explains itself. Reading goes
435
+ * through the injected model so a service that appears later needs no remount.
436
+ */
437
+ function WslPanel(props) {
438
+ const model = props !== null && props !== undefined && props.model !== undefined ? props.model : DETACHED_MODEL
439
+ const [snapshot, setSnapshot] = React.useState(model.read())
440
+ // `busy` is the path key of the write in flight; `refused` remembers that the
441
+ // Host did not accept the last one, so the panel can say so.
442
+ const [busy, setBusy] = React.useState(undefined)
443
+ const [refused, setRefused] = React.useState(false)
444
+
445
+ React.useEffect(
446
+ () =>
447
+ model.subscribe(() => {
448
+ setSnapshot(model.read())
449
+ }),
450
+ [model],
451
+ )
452
+
453
+ /**
454
+ * Send one write and reflect its outcome. The promise is returned so a
455
+ * caller (or a test) can await the settled state; React ignores the return
456
+ * value of an event handler, and `model.write` never rejects.
457
+ */
458
+ const submit = (op, key) => {
459
+ setBusy(key)
460
+ setRefused(false)
461
+ return model.write(op).then((landed) => {
462
+ setBusy(undefined)
463
+ setRefused(!landed)
464
+ setSnapshot(model.read())
465
+ return landed
466
+ })
467
+ }
468
+
469
+ /** One switch row: switch, label and hint on the left, override controls and
470
+ * the switch itself on the right. */
471
+ const rowElement = (row) => {
472
+ const key = row.path.join('.')
473
+ const overridden = hasPath(snapshot.user, row.path)
474
+ const disabled = snapshot.writable !== true || busy !== undefined
475
+ const effective = readPath(snapshot.value, row.path)
476
+ const checked = effective === undefined ? row.when : effective === true
477
+ return React.createElement(
478
+ 'div',
479
+ { key, style: ROW_STYLE },
480
+ React.createElement(
481
+ 'div',
482
+ { style: ROW_COPY_STYLE },
483
+ React.createElement(
484
+ 'div',
485
+ { style: ROW_LABEL_STYLE },
486
+ row.label,
487
+ row.danger === true ? React.createElement('span', { style: DANGER_STYLE }, '有风险') : null,
488
+ ),
489
+ React.createElement('p', { style: HINT_STYLE }, row.hint),
490
+ ),
491
+ React.createElement(
492
+ 'div',
493
+ { style: ROW_ACTIONS_STYLE },
494
+ overridden ? React.createElement('span', { style: BADGE_STYLE }, '已覆盖') : null,
495
+ overridden
496
+ ? React.createElement(
497
+ 'button',
498
+ {
499
+ type: 'button',
500
+ style: RESET_STYLE,
501
+ disabled,
502
+ // Returned as well as performed: React ignores a handler's
503
+ // return value, but awaiting it is how a test sees the write
504
+ // settle.
505
+ onClick: () => submit({ op: 'unset', path: row.path }, key),
506
+ },
507
+ '恢复默认',
508
+ )
509
+ : null,
510
+ React.createElement(primitives.Switch, {
511
+ checked,
512
+ label: row.label,
513
+ disabled,
514
+ ...(row.danger === true
515
+ ? { title: '有风险:关掉后模型可以直接执行删除、分区、关机等命令' }
516
+ : {}),
517
+ onChange: (next) => submit({ op: 'set', path: row.path, value: next === true }, key),
518
+ }),
519
+ ),
520
+ )
521
+ }
522
+
523
+ const children = []
524
+
525
+ children.push(
526
+ React.createElement(
527
+ 'header',
528
+ { key: 'header', style: HEADER_STYLE },
529
+ React.createElement('h2', { style: TITLE_STYLE }, 'WSL'),
530
+ React.createElement('p', { style: DESCRIPTION_STYLE }, '通过 WSL 在 Windows 上执行 Linux 命令的开关与说明'),
531
+ React.createElement('p', { style: NOTE_STYLE }, '改动会在下次启动 DSH 后生效'),
532
+ ),
533
+ )
534
+
535
+ // The switches — or the one reason they cannot be shown right now.
536
+ if (model.hasService() !== true) {
537
+ children.push(
538
+ React.createElement(
539
+ 'p',
540
+ { key: 'note-service', style: NOTE_STYLE },
541
+ '此 DSH 未提供配置表单服务(configForms),无法在这里调整设置。',
542
+ ),
543
+ )
544
+ } else if (snapshot.status === 'loading') {
545
+ children.push(React.createElement('p', { key: 'note-loading', style: NOTE_STYLE }, '正在读取配置…'))
546
+ } else if (snapshot.status !== 'ready') {
547
+ children.push(
548
+ React.createElement('p', { key: 'note-unavailable', style: NOTE_STYLE }, '此 DSH 未提供该插件的配置作用域'),
549
+ )
550
+ } else {
551
+ const blocks = SECTIONS.map((group) =>
552
+ section(group.id, group.title, React.createElement('div', { style: ROWS_STYLE }, group.rows.map(rowElement))),
553
+ )
554
+ if (refused) {
555
+ blocks.unshift(
556
+ React.createElement('p', { key: 'note-refused', style: NOTE_STYLE }, '保存被拒绝:配置没有被写入,请重试'),
557
+ )
558
+ }
559
+ children.push(React.createElement('div', { key: 'switches', style: STACK_STYLE }, blocks))
560
+ }
561
+
562
+ // The effective values of the two knobs that have no switch, because they
563
+ // are a distro name and a duration rather than a boolean.
564
+ if (snapshot.status === 'ready') {
565
+ const distro = readPath(snapshot.value, ['distro'])
566
+ const timeoutMs = readPath(snapshot.value, ['timeoutMs'])
567
+ children.push(
568
+ section(
569
+ 'defaults',
570
+ '当前默认值',
571
+ React.createElement(
572
+ 'div',
573
+ null,
574
+ definitionRow(
575
+ '发行版 distro',
576
+ typeof distro === 'string' && distro.trim() !== '' ? distro : '系统默认发行版',
577
+ ),
578
+ definitionRow(
579
+ '命令超时 timeoutMs',
580
+ typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs + ' 毫秒' : '—',
581
+ ),
582
+ ),
583
+ React.createElement(
584
+ 'p',
585
+ { style: HINT_STYLE },
586
+ '这两个值来自插件配置,可以在 profile 的 cordis.patch.yml 里写,也可以用环境变量 DSH_WSL_DISTRO / DSH_WSL_TIMEOUT_MS 覆盖。',
587
+ ),
588
+ ),
589
+ )
590
+ }
591
+
592
+ // Opt-in, read-only: the snippet goes ABOVE its explanation, which refers to
593
+ // it as 「上面这段」.
594
+ children.push(
595
+ section(
596
+ 'terminal',
597
+ '侧边栏 WSL 终端(可选)',
598
+ React.createElement(
599
+ 'p',
600
+ { style: HINT_STYLE },
601
+ '插件还可以把桌面端的侧边栏终端指向 WSL;这是可选项,默认不开。',
602
+ ),
603
+ React.createElement('pre', { style: PRE_STYLE }, TERMINAL_PATCH_YAML),
604
+ React.createElement(
605
+ 'p',
606
+ { style: HINT_STYLE },
607
+ '把上面这段加进 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 后重启 DSH,新建终端里就会出现 WSL(详见插件 README)',
608
+ ),
609
+ ),
610
+ )
611
+
612
+ return React.createElement('div', { style: PANEL_STYLE }, children)
613
+ }
614
+
615
+ // ------------------------------------------------------------------ apply
616
+
617
+ /**
618
+ * Mount the rail entry and the panel.
619
+ *
620
+ * This function must never throw: a client half that fails to apply takes its
621
+ * whole bundle's surface down with it, and the WSL tools work fine without any
622
+ * of this. So a missing `slots` service, a missing `configForms`, an unserved
623
+ * configuration row and a scope that refuses to be read all degrade into a note
624
+ * in the panel instead of an exception.
625
+ */
626
+ function apply(ctx) {
627
+ const model = createConfigModel()
628
+
629
+ // `configForms` is reached through `ctx.inject`, never through this module's
630
+ // own `inject` array: the panel is a convenience, not a reason to refuse to
631
+ // mount on a composition that does not ship the settings service.
632
+ try {
633
+ if (typeof ctx.inject === 'function') {
634
+ ctx.inject(['configForms'], (inner) => {
635
+ let scope
636
+ try {
637
+ scope = inner.configForms.get(CONFIG_ROW_ID)
638
+ } catch (error) {
639
+ console.error('dsh-wsl-tool: this DSH serves no "' + CONFIG_ROW_ID + '" configuration row', error)
640
+ return
641
+ }
642
+ model.attach(scope)
643
+ // The subscription lives exactly as long as this fiber: `effect` runs
644
+ // the returned disposer on unload or recomposition.
645
+ if (typeof inner.effect === 'function') {
646
+ inner.effect(() => () => model.detach(), 'dsh-wsl-tool: configuration scope')
647
+ }
648
+ })
649
+ }
650
+ } catch (error) {
651
+ console.error('dsh-wsl-tool: asking for the configuration service failed', error)
652
+ }
653
+
654
+ try {
655
+ if (ctx.slots === undefined || ctx.slots === null) {
656
+ console.error('dsh-wsl-tool: this DSH has no slots service; the WSL panel stays unmounted')
657
+ return
658
+ }
659
+ // Both seats are registered from the innermost callback, exactly as the
660
+ // shipped panels do: the rail entry and the panel it opens appear together
661
+ // or not at all. The callback's return value is the disposer chain.
662
+ ctx.slots.inject('main', () =>
663
+ ctx.slots.inject('sidebar.panellist', () => {
664
+ const stopMain = ctx.slots.register(
665
+ { name: 'main', key: PANEL_ID, inject: () => ({ model }) },
666
+ WslPanel,
667
+ )
668
+ const stopIcon = ctx.slots.register(
669
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: () => 'WSL' },
670
+ WslIcon,
671
+ )
672
+ return () => {
673
+ if (typeof stopIcon === 'function') stopIcon()
674
+ if (typeof stopMain === 'function') stopMain()
675
+ }
676
+ }),
677
+ )
678
+ } catch (error) {
679
+ console.error('dsh-wsl-tool: registering the WSL panel failed', error)
680
+ }
681
+ }
682
+
683
+ // `slots` is the one service this module needs before it can do anything.
684
+ // `configForms` is optional and is asked for above, at runtime.
685
+ const inject = ['slots']
686
+
687
+ exports.apply = apply
688
+ exports.inject = inject
689
+ return module.exports
690
+ },
691
+ })
package/lib/config.js CHANGED
@@ -1,9 +1,20 @@
1
1
  // Resolved settings for one mounted plugin instance.
2
2
  //
3
- // Everything tunable lives here. The numeric knobs are environment-overridable
4
- // so a deployment can tune them without editing this package; an unparsable or
5
- // out-of-range value falls back to the default instead of failing the mount,
6
- // because one bad environment variable must not take all three tools down.
3
+ // Everything tunable lives here, and it is resolved from three layers, most
4
+ // specific first:
5
+ //
6
+ // 1. the plugin's own configuration — the switches in the left-sidebar panel
7
+ // (a host `Config` schema; see index.js), stored in the profile patch;
8
+ // 2. the environment, for a deployment that tunes a headless install with no
9
+ // UI at all;
10
+ // 3. the built-in defaults below.
11
+ //
12
+ // A layer only speaks when it actually says something: an unset switch, an empty
13
+ // distro or a zero timeout falls THROUGH to the layer beneath instead of pinning
14
+ // the default over it. That is what keeps `DSH_WSL_WORKDIR=session` working for a
15
+ // user who never opened the panel. An unparsable or out-of-range value falls back
16
+ // rather than failing the mount, because one bad environment variable must not
17
+ // take all three tools down.
7
18
 
8
19
  import { windowsPathToWsl } from './paths.js'
9
20
 
@@ -28,6 +39,16 @@ export const DEFAULTS = {
28
39
  maxCommandChars: 30_000,
29
40
  /** `setTimeout` stores its delay in a signed 32-bit int; larger fires at once. */
30
41
  maxTimerDelayMs: 2 ** 31 - 1,
42
+ /** Which tools this plugin registers at all. */
43
+ tools: { wsl: true, path: true, env: true },
44
+ /** Whether `wsl` accepts `runInBackground` (the built-in job tools read it back). */
45
+ backgroundJobs: true,
46
+ /** Whether a Windows path in `command` is rewritten to /mnt/... by default. */
47
+ translatePaths: true,
48
+ /** Whether the destructive-command guard may be bypassed only by the caller's
49
+ * explicit `allowDangerous`. Never set this from the environment: it is the
50
+ * one switch whose off position is a footgun, and the panel marks it as such. */
51
+ dangerGuard: true,
31
52
  }
32
53
 
33
54
  /**
@@ -69,8 +90,18 @@ function envInt(env, name, fallback, min, max) {
69
90
  return value >= min && value <= max ? value : fallback
70
91
  }
71
92
 
72
- /** @returns a distribution name to pin, or null to use the system default. */
73
- function envDistro(env) {
93
+ /** A configured boolean, or the fallback when the layer did not set one. */
94
+ function setting(value, fallback) {
95
+ return typeof value === 'boolean' ? value : fallback
96
+ }
97
+
98
+ /**
99
+ * @param env - the environment layer.
100
+ * @param configured - the plugin's own configuration layer (may be undefined).
101
+ * @returns a distribution name to pin, or null to use the system default.
102
+ */
103
+ function resolveDistro(env, configured) {
104
+ if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
74
105
  const raw = env.DSH_WSL_DISTRO
75
106
  return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : null
76
107
  }
@@ -78,15 +109,19 @@ function envDistro(env) {
78
109
  /**
79
110
  * Where a call starts when the caller passes no `workdir`.
80
111
  *
81
- * `home` (the default) keeps the documented `~`. `session` starts in the
82
- * session's working directory — the plugin's own process cwd, the same source
83
- * `dsh-pwsh-local` uses by default — which is what an agent working on a
84
- * Windows checkout usually wants, since its files live at `/mnt/<drive>/...`
85
- * rather than in the Linux home. Anything else is an explicit default path.
112
+ * `home` keeps the documented `~`. `session` starts in the session's working
113
+ * directory — the plugin's own process cwd, the same source `dsh-pwsh-local` uses
114
+ * by default — which is what an agent working on a Windows checkout usually
115
+ * wants, since its files live at `/mnt/<drive>/...` rather than in the Linux home.
116
+ * Anything else is an explicit default path.
86
117
  *
87
118
  * @returns a path, or null meaning "the process working directory".
88
119
  */
89
- function envWorkdir(env) {
120
+ function resolveWorkdir(env, settings) {
121
+ // Only the ON position is authoritative: off means "whatever the layers below
122
+ // say", so `DSH_WSL_WORKDIR=session` keeps working for a user who never opened
123
+ // the panel, and a fresh install still starts in `~`.
124
+ if (settings.startInSessionWorkspace === true) return null
90
125
  const raw = env.DSH_WSL_WORKDIR
91
126
  if (typeof raw !== 'string' || raw.trim() === '') return DEFAULTS.workdir
92
127
  const value = raw.trim()
@@ -103,27 +138,45 @@ function envWorkdir(env) {
103
138
  * stream and would be discarded exactly when it is most useful.
104
139
  *
105
140
  * @param env - environment to read (injectable for tests).
141
+ * @param settings - the plugin's own configuration, as the composition resolved
142
+ * it against the Config schema (injectable for tests; may be undefined).
106
143
  */
107
- export function resolveConfig(env = process.env) {
144
+ export function resolveConfig(env = process.env, settings = {}) {
108
145
  const maxOutputBytes = envInt(
109
146
  env, 'DSH_WSL_MAX_OUTPUT_BYTES', DEFAULTS.maxOutputBytes, OUTPUT_BYTES_MIN, OUTPUT_BYTES_MAX,
110
147
  )
111
148
  const maxCommandTimeoutMs = envInt(
112
149
  env, 'DSH_WSL_MAX_TIMEOUT_MS', DEFAULTS.maxCommandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs,
113
150
  )
151
+ const tools = settings.tools ?? {}
152
+ // 0 means "not configured", so the environment and the built-in default still
153
+ // decide; a positive value is the user's own deadline.
154
+ const configuredTimeoutMs = Number.isInteger(settings.timeoutMs) && settings.timeoutMs > 0
155
+ ? settings.timeoutMs
156
+ : undefined
114
157
  return {
115
158
  ...DEFAULTS,
159
+ tools: {
160
+ wsl: setting(tools.wsl, DEFAULTS.tools.wsl),
161
+ path: setting(tools.path, DEFAULTS.tools.path),
162
+ env: setting(tools.env, DEFAULTS.tools.env),
163
+ },
164
+ backgroundJobs: setting(settings.backgroundJobs, DEFAULTS.backgroundJobs),
165
+ translatePaths: setting(settings.translatePaths, DEFAULTS.translatePaths),
166
+ dangerGuard: setting(settings.dangerGuard, DEFAULTS.dangerGuard),
167
+ startInSessionWorkspace: settings.startInSessionWorkspace === true,
116
168
  // null means "whatever wsl.exe uses by default", which is what makes the
117
169
  // package portable: `Ubuntu-22.04` exists on the author's machine, not
118
170
  // necessarily on a storefront user's.
119
- distro: envDistro(env),
120
- defaultWorkdir: envWorkdir(env),
171
+ distro: resolveDistro(env, settings.distro),
172
+ defaultWorkdir: resolveWorkdir(env, settings),
121
173
  maxOutputBytes,
122
174
  maxSpillBytes: Math.max(DEFAULTS.maxSpillBytes, maxOutputBytes),
123
175
  maxCommandTimeoutMs,
124
176
  // The default deadline obeys the cap too, so the two knobs cannot disagree.
125
177
  commandTimeoutMs: Math.min(
126
- envInt(env, 'DSH_WSL_TIMEOUT_MS', DEFAULTS.commandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs),
178
+ configuredTimeoutMs
179
+ ?? envInt(env, 'DSH_WSL_TIMEOUT_MS', DEFAULTS.commandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs),
127
180
  maxCommandTimeoutMs,
128
181
  ),
129
182
  }
package/lib/schema.js ADDED
@@ -0,0 +1,35 @@
1
+ // The schema builder behind `Config`, resolved through one interop hop.
2
+ //
3
+ // `@deepseek-ai/schemastery` exports the builder as its DEFAULT export: the module
4
+ // namespace of `import('@deepseek-ai/schemastery')` is `{ default: Schema }`, with
5
+ // no named `Schema`. Code that destructures `{ Schema }` from it gets `undefined`
6
+ // without any error, and `Schema?.object({...})` then yields `undefined` — so the
7
+ // plugin declares no `Config`, the platform has no schema to project, its settings
8
+ // namespace never appears, and the sidebar panel renders without switches. That is
9
+ // a quiet failure two layers away from its cause (measured: the Loader entry's
10
+ // config status stayed `absent` while the tools themselves worked), so the shape is
11
+ // pinned here, in one place, with a unit test.
12
+ //
13
+ // A named `Schema` export and a `Schema` property on the default are accepted too:
14
+ // other DSH packages re-export the builder in those shapes, and the check below is
15
+ // about what can actually build a schema, not about this one package.
16
+
17
+ /**
18
+ * Pick the schema builder out of a module namespace.
19
+ *
20
+ * @param namespace - a module namespace (`await import(...)`), or anything else.
21
+ * @returns the builder, or null when the namespace carries none.
22
+ */
23
+ export function pickSchemaBuilder(namespace) {
24
+ if (namespace === null || typeof namespace !== 'object') return null
25
+ const candidates = [namespace.Schema, namespace.default?.Schema, namespace.default]
26
+ for (const candidate of candidates) {
27
+ // `object` is the entry point every Config needs; requiring it here is what
28
+ // makes a wrong export shape look like a missing builder instead of a schema
29
+ // that throws later, while the composition is already being built.
30
+ if (candidate !== null && candidate !== undefined && typeof candidate.object === 'function') {
31
+ return candidate
32
+ }
33
+ }
34
+ return null
35
+ }
package/lib/tools/wsl.js CHANGED
@@ -195,7 +195,7 @@ export function createWslTool({ ctx, config, runner }) {
195
195
  },
196
196
  translatePaths: {
197
197
  type: 'boolean',
198
- description: 'Default true: rewrite Windows paths in `command` to /mnt/... . Set false to pass `command` verbatim, e.g. a native path for a Windows program launched through interop. `workdir` is always translated.',
198
+ description: `Default ${config.translatePaths}: rewrite Windows paths in \`command\` to /mnt/... . Set false to pass \`command\` verbatim, e.g. a native path for a Windows program launched through interop. \`workdir\` is always translated.`,
199
199
  },
200
200
  },
201
201
  required: ['command', 'description'],
@@ -263,9 +263,11 @@ export function createWslTool({ ctx, config, runner }) {
263
263
  }
264
264
  }
265
265
 
266
- const command = args.translatePaths === false ? args.command : windowsPathToWsl(args.command)
266
+ // The switch is the default, the parameter is the per-call override.
267
+ const translatePaths = args.translatePaths ?? config.translatePaths
268
+ const command = translatePaths === false ? args.command : windowsPathToWsl(args.command)
267
269
  const reason = destructiveReason(command)
268
- if (reason !== null && args.allowDangerous !== true) {
270
+ if (reason !== null && args.allowDangerous !== true && config.dangerGuard) {
269
271
  throw new Error(
270
272
  `wsl: refused a destructive command (${reason}). ` +
271
273
  'If this is intended, re-issue it with `allowDangerous: true`.',
@@ -283,6 +285,16 @@ export function createWslTool({ ctx, config, runner }) {
283
285
  }
284
286
 
285
287
  if (args.runInBackground === true) {
288
+ // The switch is checked here rather than hidden from the schema: a model
289
+ // that was told about `runInBackground` (by a cached prompt, or a
290
+ // transcript resumed from before the switch moved) must be told why it is
291
+ // refused instead of silently running in the foreground.
292
+ if (!config.backgroundJobs) {
293
+ throw new Error(
294
+ 'wsl: background jobs are switched off in this plugin\'s settings ' +
295
+ '(left sidebar, WSL panel -> 后台任务). Run it in the foreground, or turn the switch back on.',
296
+ )
297
+ }
286
298
  // A background job is meant to outlive the foreground deadline, so the
287
299
  // configured default does NOT apply; an explicit timeoutMs still does.
288
300
  return startInBackground(command, { ...opts, timeoutMs: args.timeoutMs }, exec)
package/package.json CHANGED
@@ -1,9 +1,23 @@
1
1
  {
2
2
  "name": "dsh-wsl-tool",
3
- "version": "1.9.1",
4
- "description": "Run Linux commands from Windows through WSL, essentially matching a native Linux DSH for command execution: background jobs, stdin, path translation, a destructive-command guard and a WSL capability report. Optionally gives the desktop app's sidebar terminal a WSL shell. WSL calls run below the DSH sandbox, and a project on a Windows drive keeps Windows filesystem semantics.",
3
+ "version": "1.10.1",
4
+ "description": "Run Linux commands from Windows through WSL, essentially matching a native Linux DSH for command execution: background jobs, stdin, path translation, a destructive-command guard and a WSL capability report. A left-sidebar panel switches each capability on or off, each with a one-line explanation, and an opt-in patch gives the desktop app's sidebar terminal a WSL shell. WSL calls run below the DSH sandbox, and a project on a Windows drive keeps Windows filesystem semantics.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "peerDependencies": {
14
+ "@deepseek-ai/schemastery": "^3.18.1"
15
+ },
16
+ "peerDependenciesMeta": {
17
+ "@deepseek-ai/schemastery": {
18
+ "optional": true
19
+ }
20
+ },
7
21
  "license": "MIT",
8
22
  "repository": {
9
23
  "type": "git",
@@ -36,7 +50,7 @@
36
50
  "LICENSE"
37
51
  ],
38
52
  "scripts": {
39
- "test": "node test/smoke.mjs",
53
+ "test": "node test/smoke.mjs && node test/client.mjs",
40
54
  "test:real": "node test/smoke.mjs --real && node test/real-seam.mjs",
41
55
  "sync": "node scripts/sync-profile.mjs"
42
56
  },
@@ -47,6 +61,13 @@
47
61
  "dsh": {
48
62
  "bundle": {
49
63
  "patch": "./cordis.patch.yml"
64
+ },
65
+ "client": {
66
+ "platform": "web",
67
+ "immediately": true,
68
+ "inject": [
69
+ "@deepseek-ai/dsh-client-ui-primitives"
70
+ ]
50
71
  }
51
72
  }
52
73
  }