dsh-wsl-desktop 0.3.7 → 0.5.0

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/lib/wsl/preset.js CHANGED
@@ -35,9 +35,19 @@ export const WORLD_ROWS = new Set([
35
35
  * depth. Beyond the five canonical rows, the persistent/terminal family ships
36
36
  * NESTED in the minimal preset's `persistent-shell` group with non-canonical
37
37
  * ids — an enabled host PowerShell persistent tool inside a preset whose name
38
- * and persona assert distro-contained execution. `tool-bash`/`tool-fs` are
39
- * deliberately absent: those names are re-mounted in their WSL form inside
40
- * the wsl-world group; their host-world forms are removed by id.
38
+ * and persona assert distro-contained execution.
39
+ *
40
+ * `tool-bash` and `tool-fs` are deliberately absent, and the reason is NOT "their
41
+ * WSL forms are re-mounted under the same package name". `tool-fs` IS re-mounted
42
+ * from `@deepseek-ai/dsh-tool-fs`; the realm's shell is
43
+ * `@deepseek-ai/dsh-tool-bash-persistent`, so a base row naming
44
+ * `@deepseek-ai/dsh-tool-bash` under a non-canonical id SURVIVES this transform
45
+ * (reported through `unclassified`, not removed, because the transform must not
46
+ * guess a row away) and would register a second `bash` beside the realm's own.
47
+ * What actually keeps exactly one owner is the row set in `buildWorldGroup`
48
+ * below, which mounts no one-shot shell row — not this list. Widening this set
49
+ * is therefore a separate decision with its own cost (it would also drop such a
50
+ * row from presets where it is the only shell), and it is not made here.
41
51
  */
42
52
  export const WORLD_MODULE_NAMES = new Set([
43
53
  '@deepseek-ai/dsh-tool-pwsh',
@@ -191,6 +201,25 @@ export function hasClassifierVerdict(name) {
191
201
  ))
192
202
  }
193
203
 
204
+ /**
205
+ * The persistent `bash` tool's model-facing description, restated LITERALLY.
206
+ *
207
+ * Nothing carries it into the variant: `prune()` records `toolConfig` only for the five
208
+ * canonical `WORLD_ROWS` ids plus `str-replace-editor`, while the persistent row is removed
209
+ * by MODULE NAME — so a bare re-mount would silently answer with the package default instead
210
+ * of the text the deployment declares. Source of truth for the text:
211
+ * `packages/bundle/web-app/presets/minimal.patch.yml:52-59`.
212
+ */
213
+ export const PERSISTENT_BASH_DESCRIPTION = [
214
+ 'Run commands in a bash shell',
215
+ '* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
216
+ '* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.',
217
+ '* State is persistent across command calls and discussions with the user.',
218
+ "* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
219
+ '* Please avoid commands that may produce a very large amount of output.',
220
+ "* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
221
+ ].join('\n')
222
+
194
223
  /**
195
224
  * Build the `wsl-world` isolate group as a plugin-entry OBJECT.
196
225
  *
@@ -202,12 +231,14 @@ export function hasClassifierVerdict(name) {
202
231
  * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
203
232
  * @param {string} options.shellPath - module specifier of the WSL shell executor.
204
233
  * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
234
+ * @param {string} options.sandboxPath - module specifier of the WSL confinement provider.
235
+ * @param {string} options.searchPath - module specifier of this plugin's WSL search tools.
205
236
  * @param {string | undefined} options.distro - distribution for paths that do not name one.
206
237
  * @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
207
238
  * @param {Map<string, object>} [options.toolConfig] - config the pruned host-world rows carried, keyed by row id.
208
239
  * @returns {object} the group entry.
209
240
  */
210
- export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor, toolConfig }) {
241
+ export function buildWorldGroup({ subprocessPath, shellPath, fsPath, sandboxPath, searchPath, distro, includeEditor, toolConfig }) {
211
242
  // The pin belongs under `config`, NOT as a sibling row key. The loader hands a
212
243
  // plugin ONLY `options.config` (`registry.plugin(plugin, this.options.config, …)`),
213
244
  // and nothing relocates row keys into it — so as a bare `distro` key every
@@ -226,22 +257,101 @@ export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, inc
226
257
  { id: 'subprocess-wsl', name: subprocessPath, ...distroConfig },
227
258
  { id: 'shell-wsl', name: shellPath, ...distroConfig },
228
259
  { id: 'fs-wsl', name: fsPath, ...distroConfig },
229
- // No terminal registry row: the Web terminal controller spawns through
230
- // `agent.ctx.get('subprocess').spawnTerminal(...)`, so the realm's
231
- // subprocess provider already puts the PTY inside the distribution.
232
- // The operator's config for these rows travels with them: re-declaring them
233
- // bare silently dropped settings the base preset had set (a non-default
234
- // maxOutputChars on the editor, enableRunInBackground on tool-bash, …), so a
235
- // value honoured in the host world vanished in the WSL world.
236
- toolRow('tool-bash', '@deepseek-ai/dsh-tool-bash'),
260
+ // The realm's own confinement provider, and the reason this group isolates `sandbox`
261
+ // (below). Two things are settled by mounting it HERE:
262
+ //
263
+ // - the realm stops asking the HOST. `ctx.get('sandbox')` inside an unisolated group
264
+ // resolved to `@deepseek-ai/dsh-sandbox-local`, whose Windows rung wraps the argv in
265
+ // an ACL restricted-token runner and grants the workspace root — an ACL grant on a
266
+ // 9P UNC spelling fails with `GetNamedSecurityInfoW … (Win32 1)`, so every terminal
267
+ // spawn in the realm died there rather than starting a shell;
268
+ // - the terminal stack gets the SAME in-distro fence the realm's shell executor
269
+ // already applies. `terminal-bash`'s `spawnArgv` wraps its argv through
270
+ // `ctx.get('sandbox')` for every mode but `danger-full-access`, and this provider
271
+ // builds that wrapper from `./confinement.js` + the distribution's own
272
+ // `dsh-wsl-confine` helper — not a second mechanism. Without this row the caller's
273
+ // argv reaches the PTY UNFENCED, which is the security face Task 1 measured.
274
+ { id: 'sandbox-wsl', name: sandboxPath, ...distroConfig },
275
+ // The realm's terminal stack, HAND-WRITTEN — never a copy of the harness's rows: those
276
+ // carry `disabled: !!js process.platform === 'win32'` (`minimal.patch.yml:44,49`), which
277
+ // is evaluated in THIS host process and would mount the row DISABLED on this Windows
278
+ // desktop (F12). The removal rule and `prune` are untouched: these entries are appended
279
+ // inside the group AFTER the prune pass, so WORLD_MODULE_NAMES still owns their
280
+ // BASE-preset twins (design §3's two directions).
281
+ //
282
+ // `dsh-terminal` provides the `terminals` service; an unisolated service provider lands
283
+ // in the ROOT realm and the mount is refused, so the group declares `terminals: true`
284
+ // below (F11). The backend registers its PTY mechanism into that registry and spawns
285
+ // through `ctx.subprocess` — which inside this realm IS this plugin's distribution-side
286
+ // PTY provider (`lib/wsl/subprocess.js:269`), so the shell really is a PTY inside the
287
+ // distribution (F7).
288
+ { id: 'pty', name: '@deepseek-ai/dsh-terminal' },
289
+ { id: 'terminal-bash', name: '@deepseek-ai/dsh-terminal-bash' },
290
+ // ONE owner of the model-facing name `bash`. The persistent row registers `bash`, and so
291
+ // did the one-shot row that used to stand further down; the harness's own minimal preset
292
+ // resolves the same question the same way — it mounts no one-shot shell row BECAUSE it
293
+ // mounts the persistent stack (`apps/cli/tests/windows-shell.spec.ts:127-134`).
294
+ //
295
+ // WHICH of the two answered here was MEASURED before the one-shot row was removed: the
296
+ // live probe read M1 = P2 — the persistent shell completed `tools.bashPersist.b` and read
297
+ // back the cwd the first call made — so the persistent row is the realm's `bash` owner.
298
+ // The one-shot row was NOT inert, though: the pre-change inventory showed BOTH `bash`
299
+ // rows `fiberState: 2` with no `broken`, so re-registration did not throw in this realm
300
+ // (the store's duplicate contract at `packages/core/scope/src/store.ts:43-45` describes
301
+ // the store, not the observed mount — ruling R33). It was a second live registrant that
302
+ // could re-win the name on a registration-order change, which is why it had to go.
303
+ //
304
+ // Removing it also removes what that package provided and this one does not: the
305
+ // `tool:bash` systemPrompt section (`packages/shell/tool-bash/src/index.ts:251-255`) and
306
+ // the jobs-backed background path. The harness's minimal preset makes the same trade
307
+ // (`packages/bundle/web-app/presets/minimal.patch.yml`), so this is parity, stated rather
308
+ // than discovered later.
309
+ //
310
+ // Sources for the measurement: the ledger's 活体验证 section (ruling R29) and
311
+ // `artifacts/live-M1-M3-c1.txt:1550`. NOT this brief and not Task 1's report — neither
312
+ // recorded a measurement.
313
+ //
314
+ // The config is restated literally: `toolRow()` cannot carry it (see
315
+ // PERSISTENT_BASH_DESCRIPTION above), and the operator-visible timeout is part of the
316
+ // contract the model lives with (the harness's own row sets `timeoutMs: 300000`).
317
+ { id: 'persistent-bash', name: '@deepseek-ai/dsh-tool-bash-persistent', config: {
318
+ timeoutMs: 300_000,
319
+ description: PERSISTENT_BASH_DESCRIPTION,
320
+ } },
321
+ // The Web terminal controller spawns through
322
+ // `agent.ctx.get('subprocess').spawnTerminal(...)`, so it needs no terminal row of its
323
+ // own: the realm's subprocess provider already puts its PTY inside the distribution.
324
+ // The operator's config for these rows travels with them: re-declaring them bare
325
+ // silently dropped settings the base preset had set (a non-default maxOutputChars on
326
+ // the editor, …), so a value honoured in the host world vanished in the WSL world.
237
327
  toolRow('tool-fs', '@deepseek-ai/dsh-tool-fs'),
238
328
  ...(includeEditor ? [toolRow('str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor')] : []),
329
+ // Searching is re-mounted the same way fs is: the two NAMES the model already knows,
330
+ // over a module of THIS plugin. It cannot be a carried host row — the host's
331
+ // search package is removed by WORLD_MODULE_NAMES, so naming it back would recreate the
332
+ // row the transform exists to drop (its argv[0] is a packaged Windows ripgrep binary).
333
+ //
334
+ // Both rows name ONE module, so the row's config is what tells that instance which tool
335
+ // to register: toolRow() is deliberately NOT used here — it would reapply the pruned
336
+ // host search row's config (a `sampleOverCapGlobResults`/caps shape this module does not
337
+ // read, and whose `tool` field would be absent), failing the row at mount time instead of
338
+ // mounting it. The tool name is also exactly `grep`/`glob`: callers invoke tools by name,
339
+ // and the row id is a separate thing that happens to spell the same.
340
+ { id: 'grep', name: searchPath, config: { tool: 'grep' } },
341
+ { id: 'glob', name: searchPath, config: { tool: 'glob' } },
239
342
  ]
240
343
  return {
241
344
  id: 'wsl-world',
242
345
  name: 'cordis:group',
243
346
  group: true,
244
- isolate: { shell: true, fs: true, subprocess: true },
347
+ // `terminals` and `sandbox` are both required keys, for the same reason: the rows above
348
+ // PROVIDE those services, and the loader refuses a provider inside a group that does not
349
+ // isolate it (`mount.ts:213`) — but the isolation is also what makes each service answer
350
+ // from inside the realm. `sandbox` is the one that changes an observable: isolated, the
351
+ // terminal stack can no longer reach the host's sandbox provider and its Windows ACL
352
+ // runner, which fails on this realm's UNC workspace. `shell`, `fs` and `subprocess` are
353
+ // the realm's own execution seams.
354
+ isolate: { shell: true, fs: true, subprocess: true, terminals: true, sandbox: true },
245
355
  config,
246
356
  }
247
357
  }
@@ -306,11 +416,13 @@ function cloneRow(row) {
306
416
  * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
307
417
  * @param {string} options.shellPath - module specifier of the WSL shell executor.
308
418
  * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
419
+ * @param {string} options.sandboxPath - module specifier of the WSL confinement provider.
420
+ * @param {string} options.searchPath - module specifier of this plugin's WSL search tools.
309
421
  * @param {string | undefined} options.distro - distribution for paths that do not name one.
310
422
  * @param {string | undefined} options.sourceDir - directory the source preset lives in.
311
423
  * @returns {{ plugins: object[], removed: string[], unclassified: { id: string | null, name: unknown }[] }} the variant rows, what was dropped, and the rows kept without a classifier verdict — an unreadable specifier, or a tool-family module that is neither removed nor verdicted-kept.
312
424
  */
313
- export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, distro, sourceDir }) {
425
+ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, sandboxPath, searchPath, distro, sourceDir }) {
314
426
  const kept = []
315
427
  const removed = []
316
428
  /** Rows kept without a classifier verdict — both shapes the prune() branches record. */
@@ -392,6 +504,6 @@ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fs
392
504
  const pruned = prune(row)
393
505
  if (pruned !== null) kept.push(pruned)
394
506
  }
395
- kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor, toolConfig }))
507
+ kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, sandboxPath, searchPath, distro, includeEditor, toolConfig }))
396
508
  return { plugins: kept, removed, unclassified }
397
509
  }