dsh-wsl-desktop 0.2.0 → 0.2.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/README.md CHANGED
@@ -63,7 +63,7 @@ harness 在会话**创建**时就把 preset 定下来(`SessionCreateRequest.ag
63
63
  两个实测前提:
64
64
 
65
65
  - **控制进程必须在桥报告会话之后才启动**,因为在桥创建 FIFO 之前 `open` 会直接失败。桥在 `_spawn()` 前先建 FIFO,靠 `started` 应答把这一点变成可观测的时序。
66
- - **rc 里向终端发查询的程序会挂住整个会话。** 这台机器的 `~/.bashrc` 末尾是 `oh-my-posh init` + `clear` + `fastfetch`;其中 `fastfetch` 会向终端发查询并等应答,而无头校验里没有终端模拟器应答,于是永远不出提示符。真实 Web 终端里 xterm.js 会应答,所以这是校验环境的问题而不是桥的缺陷 —— 但据此把校验用的 shell 固定为 `bash --noprofile --norc -i`,让校验测的是桥而不是用户的 rc。
66
+ - **rc 里向终端发查询的程序会挂住整个会话。** 有的发行版用户的 `~/.bashrc` 末尾会启动向终端发查询的程序(如 `fastfetch`),它们要等只有终端模拟器才会给的应答,而无头校验里没有应答方,于是永远不出提示符。真实 Web 终端里 xterm.js 会应答,所以这是校验环境的问题而不是桥的缺陷 —— 但据此把校验用的 shell 固定为 `bash --noprofile --norc -i`,让校验测的是桥而不是用户的 rc。
67
67
 
68
68
  PTC 的 `stdio.control`(fd 通道)仍然明确拒绝:`wsl.exe` 无法转发任意描述符。
69
69
 
package/lib/client.js CHANGED
@@ -528,7 +528,12 @@ window.__ModuleLoader__.load({
528
528
  renderedError = state.error
529
529
  }
530
530
 
531
- teardown = close
531
+ // The uninstall teardown gets forceClose, not close: a plugin
532
+ // uninstalled mid-commit must still release the overlay, and the token
533
+ // bump makes the in-flight commit's settled result drop instead of
534
+ // creating a workspace on a dead context. The dialog's own cancel paths
535
+ // (overlay click, Escape) keep the guarded close below.
536
+ teardown = forceClose
532
537
  document.addEventListener('keydown', onKey)
533
538
  hostElement().append(overlay)
534
539
  render()
@@ -552,7 +557,7 @@ window.__ModuleLoader__.load({
552
557
  }
553
558
 
554
559
  /** The injected services the picker and its trigger read. */
555
- const companion = { button: null, after: null, observer: null, scheduled: false }
560
+ const companion = { button: null, trigger: null, observer: null, scheduled: false }
556
561
 
557
562
  /** Find the shipped "add workspace" trigger by its icon geometry. */
558
563
  function findTrigger() {
@@ -608,10 +613,19 @@ window.__ModuleLoader__.load({
608
613
  companion.scheduled = false
609
614
  const { button } = companion
610
615
  if (button === null) return
611
- const trigger = findTrigger()
616
+ // Steady-state fast path: the cached trigger stays valid while it
617
+ // remains connected inside the trailing action cluster, so chat
618
+ // streaming (a mutation every frame) re-runs only the cheap placement
619
+ // and visibility checks below instead of the whole-document icon scan.
620
+ // The cache self-heals: a replaced or relocated trigger is disconnected
621
+ // and forces one full rescan.
622
+ let trigger = companion.trigger
623
+ if (trigger === null || !trigger.isConnected || !hasHeaderActionsParent(trigger)) {
624
+ trigger = findTrigger()
625
+ companion.trigger = trigger
626
+ }
612
627
  if (trigger === null) {
613
628
  if (button.isConnected) button.remove()
614
- companion.after = null
615
629
  return
616
630
  }
617
631
  const target = actionCluster(trigger)
@@ -622,7 +636,6 @@ window.__ModuleLoader__.load({
622
636
  button.className = trigger.className
623
637
  button.style.display = ''
624
638
  target.after(button)
625
- companion.after = target
626
639
  }
627
640
  // Measure with the button rendered, then decide. The header row clips its
628
641
  // own overflow, and the shipped action cluster hides itself while the
@@ -693,7 +706,7 @@ window.__ModuleLoader__.load({
693
706
  observer.disconnect()
694
707
  button.remove()
695
708
  companion.button = null
696
- companion.after = null
709
+ companion.trigger = null
697
710
  companion.observer = null
698
711
  teardown?.()
699
712
  }
package/lib/index.js CHANGED
@@ -130,9 +130,6 @@ function sourceDirFor(id) {
130
130
  return undefined
131
131
  }
132
132
 
133
- /** Disposers of the variants this plugin registered; the plugin owns their lifecycle. */
134
- const variantDisposers = new Map()
135
-
136
133
  /**
137
134
  * Read a base preset's declared plugin rows as OBJECTS.
138
135
  *
@@ -159,14 +156,18 @@ function basePluginsOf(presets, id) {
159
156
  * variant cleanly.
160
157
  * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
161
158
  * @param {string | undefined} distro - distribution to pin.
162
- * @param {{ disposed: boolean }} generation - this registration generation's
163
- * lifecycle token; the plugin's teardown flips `disposed` synchronously
164
- * before draining, and the loop re-checks it around every await so a
165
- * disable landing mid-registration cannot orphan a variant whose disposer
166
- * nobody owns.
159
+ * @param {{ disposed: boolean, disposers: Map<string, Function> }} generation - this
160
+ * registration generation's lifecycle token; the plugin's teardown flips
161
+ * `disposed` synchronously before draining, and the loop re-checks it around
162
+ * every await so a disable landing mid-registration cannot orphan a variant
163
+ * whose disposer nobody owns. The disposer map lives ON the generation, so
164
+ * one generation's teardown drains only the variants it registered.
165
+ * @param {{ disposers: Map<string, Function>} | null} previous - the generation
166
+ * whose variants must be withdrawn before this one registers (register()
167
+ * refuses duplicates).
167
168
  * @returns {Promise<object>} the registration outcome for the route.
168
169
  */
169
- async function materializePresets(ctx, distro, generation) {
170
+ async function materializePresets(ctx, distro, generation, previous) {
170
171
  const presets = ctx.get('agentPresets')
171
172
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
172
173
  // 版本门控:本插件的目标面是 0.1.7 的运行时注册架构。旧宿主(无
@@ -183,9 +184,9 @@ async function materializePresets(ctx, distro, generation) {
183
184
  const skipped = []
184
185
 
185
186
  // Withdraw the previous generation first: register() refuses duplicates.
186
- for (const [id, dispose] of variantDisposers) {
187
+ for (const [id, dispose] of previous?.disposers ?? []) {
187
188
  try { await dispose() } catch { /* already gone */ }
188
- variantDisposers.delete(id)
189
+ previous?.disposers.delete(id)
189
190
  if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
190
191
  }
191
192
 
@@ -236,7 +237,7 @@ async function materializePresets(ctx, distro, generation) {
236
237
  skipped.push({ id: variantId, reason: '插件已卸载,注册即撤回' })
237
238
  continue
238
239
  }
239
- variantDisposers.set(variantId, dispose)
240
+ generation.disposers.set(variantId, dispose)
240
241
  written.push({ id: variantId, from: id, removed })
241
242
  } catch (error) {
242
243
  const message = error instanceof Error ? error.message : String(error)
@@ -264,9 +265,17 @@ let currentGeneration = null
264
265
  */
265
266
  async function refreshPresets(ctx, distro, generation = currentGeneration) {
266
267
  const run = async () => {
268
+ // A caller may omit the generation (developer-token regenerate before the
269
+ // inject ran): mint a transient one so every disposer has an owner.
270
+ const gen = generation ?? { disposed: false, disposers: new Map() }
271
+ // Withdrawal targets the PREVIOUS generation's disposers; a self-refresh
272
+ // (developer-token regenerate) withdraws its own — the exact behaviour of
273
+ // the historical shared map, minus the cross-generation teardown theft.
274
+ const previous = currentGeneration === gen ? gen : currentGeneration
275
+ currentGeneration = gen
267
276
  presetState = { status: 'running' }
268
277
  try {
269
- const result = await materializePresets(ctx, distro, generation)
278
+ const result = await materializePresets(ctx, distro, gen, previous)
270
279
  presetState = { status: 'ready', ...result }
271
280
  } catch (error) {
272
281
  presetState = { status: 'failed', error: error instanceof Error ? error.message : String(error) }
@@ -975,12 +984,15 @@ export function apply(ctx, config) {
975
984
  // await re-checks the flag around every register() and hands any
976
985
  // just-registered variant straight back to its disposer, so a disable
977
986
  // landing mid-registration cannot orphan a wsl-* preset in the registry.
978
- const generation = { disposed: false }
979
- currentGeneration = generation
987
+ // Per-generation disposer ownership: one generation's teardown drains only
988
+ // the variants ITS registration created, so a second generation mounted
989
+ // beside a running one (the documented same-process exercise path) can no
990
+ // longer have its variants torn down by the first generation's dispose.
991
+ const generation = { disposed: false, disposers: new Map() }
980
992
  scope.effect(() => () => {
981
993
  generation.disposed = true
982
- for (const [, dispose] of variantDisposers) void dispose()
983
- variantDisposers.clear()
994
+ for (const [, dispose] of generation.disposers) void dispose()
995
+ generation.disposers.clear()
984
996
  }, 'wsl-desktop: variant preset disposal')
985
997
  void defaultDistro().then(
986
998
  (distro) => refreshPresets(scope, distro, generation),
@@ -405,12 +405,16 @@ export function buildConfinedCommand({ command, linuxCwd, mode, workspaceLinuxRo
405
405
  // fence itself and setpriv-exec's the command with NO_NEW_PRIVS — the
406
406
  // command text travels as argv after `--` and is never evaluated as root.
407
407
  if (runner === RUNNER_HELPER) {
408
+ // Mirrors the direct branch's mode guard: only workspace-write may hand
409
+ // the helper a writable root. workspaceRootInLinux() returns the command's
410
+ // cwd when the policy carries no root, so without this check a read-only
411
+ // command would arrive with a bound, WRITABLE workspace.
408
412
  const flags = [
409
413
  `--uid ${identity.uid}`,
410
414
  `--gid ${identity.gid}`,
411
415
  `--home ${shellQuote(identity.home)}`,
412
416
  `--cwd ${shellQuote(linuxCwd)}`,
413
- ...(workspaceLinuxRoot !== undefined && workspaceLinuxRoot !== '' ? [`--workspace ${shellQuote(workspaceLinuxRoot)}`] : []),
417
+ ...(mode === 'workspace-write' && workspaceLinuxRoot !== undefined && workspaceLinuxRoot !== '' ? [`--workspace ${shellQuote(workspaceLinuxRoot)}`] : []),
414
418
  ...(isolateProcesses ? [] : ['--no-pidns']),
415
419
  '--',
416
420
  shellQuote(command),
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # dsh-wsl-confine v1 — DSH WSL confinement helper.
2
+ # dsh-wsl-confine v1.1 — DSH WSL confinement helper.
3
3
  #
4
4
  # Root-owned fence executor: the ONLY thing this helper does is apply the
5
5
  # mount-namespace fence (workspace bind, tmpfs /tmp, read-only /, read-only
@@ -17,9 +17,13 @@
17
17
  # Contract: parameters are strictly validated (numeric uid/gid, absolute
18
18
  # paths); the caller's command travels as argv after `--` and is executed only
19
19
  # AFTER the setpriv drop (with NO_NEW_PRIVS) — root never evaluates caller text.
20
+ # The drop identity is checked against the INVOKING user (SUDO_USER): the
21
+ # sudoers grant is argument-wildcarded, and an unchecked --uid would let the
22
+ # session user aim it at uid 0 — the fence is a WRITE boundary, so uid 0 inside
23
+ # it still reads every root-only file. Root's own direct invocation has no
24
+ # SUDO_USER and skips the check.
20
25
  set -euo pipefail
21
- VERSION='dsh-wsl-confine v1'
22
- SETUP_FAILURE_EXIT=97
26
+ VERSION='dsh-wsl-confine v1.1'
23
27
 
24
28
  uid=; gid=; home=; cwd=; workspace=; pidns=1
25
29
  ARGS=()
@@ -40,7 +44,19 @@ done
40
44
  [[ "$uid" =~ ^[0-9]+$ && "$gid" =~ ^[0-9]+$ ]] || { echo 'dsh-wsl-confine: uid/gid must be numeric' >&2; exit 2; }
41
45
  [[ -n "$cwd" && "$cwd" = /* && -n "$home" && "$home" = /* ]] || { echo 'dsh-wsl-confine: --cwd/--home must be absolute Linux paths' >&2; exit 2; }
42
46
  [[ -z "$workspace" || "$workspace" = /* ]] || { echo 'dsh-wsl-confine: --workspace must be absolute' >&2; exit 2; }
43
- ((${#ARGS[@]} >= 1)) || { echo 'dsh-wsl-confine: no command' >&2; exit 2 }
47
+ ((${#ARGS[@]} >= 1)) || { echo 'dsh-wsl-confine: no command' >&2; exit 2; }
48
+
49
+ # Identity gate: the caller may only drop to the user sudo says is invoking.
50
+ if [[ -n "${SUDO_USER:-}" ]]; then
51
+ CALLER_RECORD=$(getent passwd "$SUDO_USER" || true)
52
+ CALLER_UID=$(printf '%s' "$CALLER_RECORD" | cut -d: -f3)
53
+ CALLER_GID=$(printf '%s' "$CALLER_RECORD" | cut -d: -f4)
54
+ if [[ -z "$CALLER_UID" || "$uid" != "$CALLER_UID" || "$gid" != "$CALLER_GID" ]]; then
55
+ echo "dsh-wsl-confine: identity mismatch (refusing --uid/--gid for ${SUDO_USER})" >&2
56
+ exit 2
57
+ fi
58
+ fi
59
+
44
60
  command -v unshare >/dev/null 2>&1 || { echo 'dsh-wsl-confine: unshare not found' >&2; exit 97; }
45
61
  command -v setpriv >/dev/null 2>&1 || { echo 'dsh-wsl-confine: setpriv not found' >&2; exit 97; }
46
62
  command -v findmnt >/dev/null 2>&1 || { echo 'dsh-wsl-confine: findmnt not found' >&2; exit 97; }
@@ -48,33 +64,38 @@ command -v findmnt >/dev/null 2>&1 || { echo 'dsh-wsl-confine: findmnt not found
48
64
  # The fence script is built HERE from the validated parameters — the caller
49
65
  # never supplies script text. It is identical in effect to the in-process
50
66
  # builder (bind workspace before ro, tmpfs /tmp, decoded sweep, postconditions,
51
- # NO_NEW_PRIVS drop).
67
+ # NO_NEW_PRIVS drop). Parameters cross into `bash -c` as ENVIRONMENT
68
+ # VARIABLES via env(1): bare KEY=VALUE words after the script name would be
69
+ # positional parameters no variable reference can read, and $UID/$GID are
70
+ # bash built-ins that would silently resolve to root's ids under sudo. The
71
+ # exemption pattern anchors ONLY at the group end with an escaped trailing
72
+ # `$` — a literal `/sys$")$"` tail is a bash parse error, not a regex.
52
73
  FENCE='set -euo pipefail
53
74
  fail() { printf "%s: %s\n" "dsh-wsl-confine" "$1" >&2; exit 97; }
54
- if [[ -n "${WORKSPACE:-}" ]]; then
55
- mount --bind "$WORKSPACE" "$WORKSPACE"
56
- KEEP=("/tmp" "$WORKSPACE")
75
+ if [[ -n "${DROP_WORKSPACE:-}" ]]; then
76
+ mount --bind "$DROP_WORKSPACE" "$DROP_WORKSPACE"
77
+ KEEP=("/tmp" "$DROP_WORKSPACE")
57
78
  else
58
79
  KEEP=("/tmp")
59
80
  fi
60
81
  mount -t tmpfs tmpfs /tmp
61
82
  mount -o remount,ro,bind /
62
- findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | { grep -Ev "^($(printf "%s|" "${KEEP[@]}" | sed "s/|$//")|/dev$|/proc$|/sys$")$" || fail "exemption grep failed"; } | while IFS= read -r target; do
83
+ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | { grep -Ev "^($(printf "%s|" "${KEEP[@]}" | sed "s/|$//")|/dev|/proc|/sys)\$" || fail "exemption grep failed"; } | while IFS= read -r target; do
63
84
  mount -o remount,ro,bind "$target" >/dev/null 2>&1 || true
64
85
  done
65
86
  mountpoint -q /tmp || fail "/tmp is not a private tmpfs"
66
87
  findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"
67
- findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | { grep -Ev "^($(printf "%s|" "${KEEP[@]}" | sed "s/|$//")|/dev$|/proc$|/sys$")$" || fail "exemption grep failed"; } | while IFS= read -r target; do
88
+ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | { grep -Ev "^($(printf "%s|" "${KEEP[@]}" | sed "s/|$//")|/dev|/proc|/sys)\$" || fail "exemption grep failed"; } | while IFS= read -r target; do
68
89
  [[ -w "$target" ]] && fail "$target is still writable"
69
90
  true
70
91
  done
71
- cd "$CWD"
72
- exec setpriv --no-new-privs --reuid="$UID" --regid="$GID" --init-groups env HOME="$HOME_DIR" USER="$USER_NAME" LOGNAME="$USER_NAME" bash -lc "$COMMAND"'
92
+ cd "$DROP_CWD"
93
+ exec setpriv --no-new-privs --reuid="$DROP_UID" --regid="$DROP_GID" --init-groups env HOME="$DROP_HOME" USER="$DROP_USER" LOGNAME="$DROP_USER" bash -lc "$DROP_COMMAND"'
73
94
 
74
95
  PID_FLAG=''
75
96
  [[ "$pidns" = 1 ]] && PID_FLAG='--pid --fork'
76
97
 
77
- exec unshare --mount --propagation private $PID_FLAG bash -c "$FENCE" \
78
- dsh-wsl-confine \
79
- UID="$uid" GID="$gid" HOME_DIR="$home" USER_NAME="${SUDO_USER:-user}" \
80
- CWD="$cwd" WORKSPACE="$workspace" COMMAND="${ARGS[*]}"
98
+ exec unshare --mount --propagation private $PID_FLAG env \
99
+ DROP_UID="$uid" DROP_GID="$gid" DROP_HOME="$home" DROP_USER="${SUDO_USER:-root}" \
100
+ DROP_CWD="$cwd" DROP_WORKSPACE="$workspace" DROP_COMMAND="${ARGS[*]}" \
101
+ bash -c "$FENCE" dsh-wsl-confine
package/lib/wsl/preset.js CHANGED
@@ -4,36 +4,23 @@
4
4
  * A session's execution world is chosen by its preset, not by a global router:
5
5
  * the model also needs a different tool dialect (bash rather than PowerShell),
6
6
  * and one process-wide provider could not vary that per session. This module
7
- * therefore rewrites a shipped preset's entry list — it removes the rows that
8
- * name the host execution world and appends one `isolate` group that mounts the
9
- * WSL world plus the tools that consume it.
7
+ * therefore rewrites a shipped preset's plugin entry OBJECTS — it removes the
8
+ * rows that name the host execution world (by canonical id at the top level
9
+ * and by module name at every depth) and appends one `isolate` group that
10
+ * mounts the WSL world plus the tools that consume it.
10
11
  *
11
- * The transform is pure text over the loader's entry list, so it is unit
12
- * testable without the harness. Rows are matched by their top-level `- id:`
13
- * boundary; only top-level (two-space indented) keys are rewritten, so a nested
14
- * entry inside a group's `config:` is never touched.
12
+ * The transform is pure over the loader's entry objects, so it is unit
13
+ * testable without the harness (`scripts/verify-preset.mjs`). The historical
14
+ * 0.1.6 YAML-text renderer lived and died with the disk-generation pipeline:
15
+ * since 0.1.7 variants are registered programmatically via
16
+ * `agentPresets.register({ id, name, plugins })` and the registry, not a
17
+ * generated directory, is the source of truth.
15
18
  * @module dsh-wsl-desktop/wsl/preset
16
19
  */
17
20
 
18
21
  import { join } from 'node:path'
19
22
  import { pathToFileURL } from 'node:url'
20
23
 
21
- /**
22
- * Decide what happens to one directory inside this plugin's preset namespace.
23
- *
24
- * The namespace is a name prefix, so it is shared with anything a user chooses
25
- * to call `wsl-…`. Only a directory this plugin wrote — one carrying its marker
26
- * — may be withdrawn; an unmarked one is the user's and is reported instead of
27
- * deleted.
28
- * @param {{ name: string, prefix: string, expected: boolean, marked: boolean }} entry - one directory.
29
- * @returns {'ignore' | 'keep' | 'withdraw' | 'unmanaged'} the disposition.
30
- */
31
- export function sweepDecision({ name, prefix, expected, marked }) {
32
- if (!name.startsWith(prefix)) return 'ignore'
33
- if (expected) return 'keep'
34
- return marked ? 'withdraw' : 'unmanaged'
35
- }
36
-
37
24
  /** Rows that name the host execution world and must not survive in a WSL preset. */
38
25
  export const WORLD_ROWS = new Set([
39
26
  'tool-bash',
@@ -113,6 +100,16 @@ export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, inc
113
100
  }
114
101
  }
115
102
 
103
+ /**
104
+ * The sentence appended to the preset's persona.
105
+ *
106
+ * A session's recorded cwd is the UNC spelling (`\\wsl.localhost\<distro>\…`)
107
+ * because that is the only form the Windows-side harness accepts, while the
108
+ * shell in that session reports a Linux path. Without this the model is told one
109
+ * spelling and observes another.
110
+ */
111
+ export const WSL_PERSONA_SENTENCE = 'Paths under \\\\wsl.localhost\\<distro> are Windows spellings of directories inside that WSL distribution — \\\\wsl.localhost\\<distro>\\home\\me is /home/me there. Every command runs in the distribution, so use Linux paths, and reach Windows files as /mnt/<drive>/…'
112
+
116
113
  /**
117
114
  * Append the WSL path-dialect sentence to a persona row OBJECT.
118
115
  * @param {object} row - the persona entry.
@@ -130,22 +127,6 @@ function appendPersonaSuffixObject(row) {
130
127
  return true
131
128
  }
132
129
 
133
- /**
134
- * Transform a base preset's plugin ENTRY OBJECTS into the WSL variant's list.
135
- *
136
- * Same semantics as the historical YAML rewrite: drop the rows that name the
137
- * host execution world, amend the persona with the path-dialect sentence,
138
- * rewrite relative row names against the source directory, and append the
139
- * `wsl-world` isolate group.
140
- * @param {readonly object[]} basePlugins - the base preset's plugin rows.
141
- * @param {object} options - generation options.
142
- * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
143
- * @param {string} options.shellPath - module specifier of the WSL shell executor.
144
- * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
145
- * @param {string | undefined} options.distro - distribution for paths that do not name one.
146
- * @param {string | undefined} options.sourceDir - directory the source preset lives in.
147
- * @returns {{ plugins: object[], removed: string[] }} the variant rows and what was dropped.
148
- */
149
130
  /**
150
131
  * Clone one entry row deeply enough to transform it safely. Group rows carry
151
132
  * their children under `config` as an ARRAY (recursive tree), so the clone
@@ -165,6 +146,22 @@ function cloneRow(row) {
165
146
  return clone
166
147
  }
167
148
 
149
+ /**
150
+ * Transform a base preset's plugin ENTRY OBJECTS into the WSL variant's list.
151
+ *
152
+ * Drop the rows that name the host execution world (id fast path, module-name
153
+ * match at every depth), amend the persona with the path-dialect sentence,
154
+ * rewrite relative row names against the source directory, and append the
155
+ * `wsl-world` isolate group.
156
+ * @param {readonly object[]} basePlugins - the base preset's plugin rows.
157
+ * @param {object} options - generation options.
158
+ * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
159
+ * @param {string} options.shellPath - module specifier of the WSL shell executor.
160
+ * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
161
+ * @param {string | undefined} options.distro - distribution for paths that do not name one.
162
+ * @param {string | undefined} options.sourceDir - directory the source preset lives in.
163
+ * @returns {{ plugins: object[], removed: string[] }} the variant rows and what was dropped.
164
+ */
168
165
  export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, distro, sourceDir }) {
169
166
  const kept = []
170
167
  const removed = []
@@ -222,235 +219,3 @@ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fs
222
219
  kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }))
223
220
  return { plugins: kept, removed }
224
221
  }
225
-
226
- /**
227
- * The sentence appended to the preset's persona.
228
- *
229
- * A session's recorded cwd is the UNC spelling (`\\wsl.localhost\<distro>\…`)
230
- * because that is the only form the Windows-side harness accepts, while the
231
- * shell in that session reports a Linux path. Without this the model is told one
232
- * spelling and observes another.
233
- */
234
- export const WSL_PERSONA_SENTENCE = 'Paths under \\\\wsl.localhost\\<distro> are Windows spellings of directories inside that WSL distribution — \\\\wsl.localhost\\<distro>\\home\\me is /home/me there. Every command runs in the distribution, so use Linux paths, and reach Windows files as /mnt/<drive>/…'
235
-
236
- /**
237
- * Append the WSL sentence to a persona row's text field.
238
- *
239
- * Handles the three shapes a preset may use: an inline `suffix`, a block-scalar
240
- * `suffix`, and a persona that carries only `text`. A row with none of them
241
- * gains a `suffix` under `config:`.
242
- * @param {{ id: string | null, lines: string[] }} block - the persona block.
243
- * @returns {boolean} whether the block was amended.
244
- */
245
- export function appendPersonaSuffix(block) {
246
- const lines = block.lines
247
- const configIndex = lines.findIndex((line) => /^(\s*)config:\s*$/.test(line))
248
- if (configIndex === -1) return false
249
- const configIndent = (/^(\s*)config:\s*$/.exec(lines[configIndex])?.[1] ?? '').length
250
- const childIndent = configIndent + 2
251
- const child = new RegExp(`^ {${childIndent}}(suffix|text|prefix):(.*)$`)
252
- for (const field of ['suffix', 'text', 'prefix']) {
253
- const index = lines.findIndex((line, at) => at > configIndex && child.test(line) && new RegExp(`^ {${childIndent}}${field}:`).test(line))
254
- if (index === -1) continue
255
- const value = (new RegExp(`^ {${childIndent}}${field}:(.*)$`).exec(lines[index])?.[1] ?? '').trim()
256
- // A block scalar (`>-`, `|`, `|-`, …) continues on the following lines.
257
- if (value === '' || /^[|>][-+]?\d*$/.test(value)) {
258
- lines.splice(index + 1, 0, `${' '.repeat(childIndent + 2)}${WSL_PERSONA_SENTENCE}`)
259
- } else if ((value.startsWith("'") && value.endsWith("'") && value.length >= 2)
260
- || (value.startsWith('"') && value.endsWith('"') && value.length >= 2)) {
261
- // A quoted scalar must grow INSIDE its quotes; appending after a closing
262
- // quote would produce invalid YAML.
263
- const quote = value[0]
264
- const sentence = quote === "'" ? WSL_PERSONA_SENTENCE.replace(/'/g, "''") : WSL_PERSONA_SENTENCE
265
- lines[index] = `${lines[index].slice(0, -1)} ${sentence}${quote}`
266
- } else {
267
- lines[index] = `${lines[index]} ${WSL_PERSONA_SENTENCE}`
268
- }
269
- return true
270
- }
271
- // No text field of its own: gain one as the first key under `config:`.
272
- lines.splice(configIndex + 1, 0, `${' '.repeat(childIndent)}suffix: ${WSL_PERSONA_SENTENCE}`)
273
- return true
274
- }
275
-
276
- /**
277
- * Quote one scalar for a YAML single-quoted value.
278
- * @param {string} value - the raw value.
279
- * @returns {string} the quoted scalar.
280
- */
281
- function quote(value) {
282
- return `'${value.replace(/'/g, "''")}'`
283
- }
284
-
285
- /**
286
- * Split an entry list into top-level blocks.
287
- * @param {string} source - the loader entry list.
288
- * @returns {Array<{ id: string | null, lines: string[] }>} the blocks in order.
289
- */
290
- export function splitBlocks(source) {
291
- const blocks = []
292
- let current = { id: null, lines: [] }
293
- for (const line of source.split('\n')) {
294
- const match = /^- id:\s*(.+?)\s*$/.exec(line)
295
- if (match !== null) {
296
- if (current.lines.length > 0 || current.id !== null) blocks.push(current)
297
- // Normalise the captured id so every YAML spelling of the same row
298
- // resolves to one canonical id for world-row removal: strip trailing
299
- // comments, YAML anchors (`&a name`), and surrounding quotes.
300
- let id = match[1].replace(/\s+#.*$/, '').trim()
301
- if (id.startsWith('&')) id = id.replace(/^&\S+\s+/, '')
302
- id = id.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
303
- current = { id, lines: [line] }
304
- continue
305
- }
306
- current.lines.push(line)
307
- }
308
- blocks.push(current)
309
- return blocks
310
- }
311
-
312
- /**
313
- * Build the `wsl-world` group that carries the WSL execution world.
314
- *
315
- * The group isolates every service a row inside it publishes, so one session
316
- * can run in a distribution while the process keeps serving Windows sessions.
317
- * `subprocess-wsl` precedes the consumers that inject it: the shell executor
318
- * hands it a Linux argv and lets the provider own the `wsl.exe` wrapper.
319
- * @param {object} options - generation options.
320
- * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
321
- * @param {string} options.shellPath - module specifier of the WSL shell executor.
322
- * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
323
- * @param {string | undefined} options.distro - distribution for paths that do not name one.
324
- * @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
325
- * @returns {string[]} the group's YAML lines.
326
- */
327
- function worldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }) {
328
- const distroConfig = distro === undefined ? [] : [' config:', ` distro: ${quote(distro)}`]
329
- return [
330
- '- id: wsl-world',
331
- ' name: cordis:group',
332
- ' group: true',
333
- ' isolate:',
334
- ' shell: true',
335
- ' fs: true',
336
- ' subprocess: true',
337
- ' config:',
338
- ' - id: subprocess-wsl',
339
- ` name: ${quote(subprocessPath)}`,
340
- ...distroConfig,
341
- ' - id: shell-wsl',
342
- ` name: ${quote(shellPath)}`,
343
- ...distroConfig,
344
- ' - id: fs-wsl',
345
- ` name: ${quote(fsPath)}`,
346
- ...distroConfig,
347
- // No terminal registry row: the Web terminal controller spawns through
348
- // `agent.ctx.get('subprocess').spawnTerminal(...)`
349
- // (packages/api/terminal-controller/src/index.ts:333,346), so the realm's
350
- // subprocess provider already puts the PTY inside the distribution. A
351
- // `terminals` realm would only serve the model's persistent-shell tool,
352
- // which this preset does not mount.
353
- ' - id: tool-bash',
354
- " name: '@deepseek-ai/dsh-tool-bash'",
355
- ' - id: tool-fs',
356
- " name: '@deepseek-ai/dsh-tool-fs'",
357
- ...includeEditor
358
- ? [' - id: str-replace-editor', " name: '@deepseek-ai/dsh-tool-str-replace-editor'"]
359
- : [],
360
- ]
361
- }
362
-
363
- /**
364
- * Rewrite relative row names so they still resolve from the variant directory.
365
- *
366
- * A preset may name a row by a path relative to its own directory
367
- * (`./tool-bootstrap.mjs`). The variant lives in a different directory, so the
368
- * relative spelling would resolve against the wrong base and the preset would
369
- * fail to mount.
370
- * @param {{ lines: string[] }} block - one top-level entry.
371
- * @param {string} sourceDir - directory of the preset being derived from.
372
- * @returns {number} how many names were rewritten.
373
- */
374
- export function rewriteRelativeNames(block, sourceDir) {
375
- let rewritten = 0
376
- block.lines = block.lines.map((line) => {
377
- const match = /^(\s*)name:\s*['"]?\.\/([^'"]+)['"]?\s*$/.exec(line)
378
- if (match === null) return line
379
- const absolute = join(sourceDir, match[2]).replace(/\\/g, '/')
380
- rewritten += 1
381
- return `${match[1]}name: '${absolute}'`
382
- })
383
- return rewritten
384
- }
385
-
386
- /**
387
- * Rewrite one shipped preset into its WSL variant.
388
- *
389
- * The whole preset is wrapped in the realm rather than sitting beside it. A
390
- * sibling group would leave every consumer outside it — a persistent-shell
391
- * group, a hand-written tool row — resolving the host's providers, which is the
392
- * Windows execution world; wrapping makes the realm the preset's own scope, so
393
- * every row resolves the WSL providers. Rows that hardcode Windows tooling are
394
- * dropped and re-mounted in their WSL form.
395
- * @param {string} source - the shipped preset's `agent.cordis.yml` text.
396
- * @param {object} options - generation options.
397
- * @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
398
- * @param {string} options.shellPath - module specifier of the WSL shell executor.
399
- * @param {string} options.fsPath - module specifier of the WSL filesystem provider.
400
- * @param {string} [options.distro] - distribution for paths that do not name one.
401
- * @param {string} [options.sourceDir] - directory the source preset lives in.
402
- * @returns {{ yaml: string, removed: string[], added: boolean }} the rewritten preset and what changed.
403
- */
404
- export function renderWslPreset(source, options) {
405
- const blocks = splitBlocks(source)
406
- const kept = []
407
- const removed = []
408
- let includeEditor = false
409
- let personaAmended = false
410
- for (const block of blocks) {
411
- if (block.id !== null && WORLD_ROWS.has(block.id)) {
412
- removed.push(block.id)
413
- if (block.id === 'str-replace-editor') includeEditor = true
414
- continue
415
- }
416
- if (block.id === 'persona') personaAmended = appendPersonaSuffix(block)
417
- if (options.sourceDir !== undefined) rewriteRelativeNames(block, options.sourceDir)
418
- kept.push(block)
419
- }
420
- // `split('\n')` consumed the separators, so blocks rejoin with one newline;
421
- // joining with '' would glue the next `- id:` onto the previous line.
422
- const body = kept.map((block) => block.lines.join('\n')).join('\n')
423
- // One level down: the original entry list becomes the group's `config` list.
424
- const nested = body
425
- .split('\n')
426
- .map((line) => (line.trim() === '' ? line : ` ${line}`))
427
- .join('\n')
428
- const group = worldGroup({
429
- subprocessPath: options.subprocessPath,
430
- shellPath: options.shellPath,
431
- fsPath: options.fsPath,
432
- distro: options.distro,
433
- includeEditor,
434
- })
435
- return {
436
- yaml: `${group.join('\n')}\n${nested}\n`,
437
- removed,
438
- added: true,
439
- }
440
- }
441
-
442
- /**
443
- * Render the preset's display metadata.
444
- * @param {{ name: string, description: string }} metadata - display name and description.
445
- * @returns {string} the `preset.yml` text.
446
- */
447
- export function renderPresetMetadata(metadata) {
448
- // Quote only when the value contains characters that YAML would
449
- // misinterpret; plain alphanumeric + CJK + basic punctuation stays unquoted.
450
- const scalar = (value) => {
451
- const s = String(value)
452
- if (/^[\w.\-\u4e00-\u9fff\u3000-\u303f\u00b7(): ]+$/u.test(s) && !/^\s|\s$/.test(s) && !s.includes(': ') && !s.startsWith('#')) return s
453
- return `'${s.replace(/'/g, "''")}'`
454
- }
455
- return `name: ${scalar(metadata.name)}\ndescription: ${scalar(metadata.description)}\n`
456
- }
package/lib/wsl/shell.js CHANGED
@@ -344,7 +344,12 @@ export class WslShellExecutor extends ShellExecutor {
344
344
  sandbox: { ...sandbox },
345
345
  readOutput() {
346
346
  const stdoutRead = stdout.readFrom(readOffset)
347
- readOffset += stdoutRead.text.length
347
+ // Resume from the reader's own nextOffset, not `text.length`: the
348
+ // collect contract is BYTE-offset based (SubprocessOutputRead), and
349
+ // a UTF-16 code-unit count diverges from it for any non-ASCII
350
+ // output — the second read would resume mid-stream and duplicate or
351
+ // garble text. start() already advanced this way.
352
+ readOffset = stdoutRead.nextOffset
348
353
  const stderrRead = stderr.readFrom(0)
349
354
  return {
350
355
  delta: stderrRead.text !== '' ? `${stdoutRead.text}\n[stderr]\n${stderrRead.text}` : stdoutRead.text,
@@ -159,8 +159,14 @@ class Bridge:
159
159
 
160
160
  op = request.get("op")
161
161
  if op == "resize":
162
- self.resize(request.get("cols", 80), request.get("rows", 24))
163
- ans({"ok": True})
162
+ try:
163
+ self.resize(request.get("cols", 80), request.get("rows", 24))
164
+ ans({"ok": True})
165
+ except (TypeError, ValueError, OSError) as error:
166
+ # A malformed payload must not kill the session: the bridge IS
167
+ # the terminal, so an unhandled exception in this dispatch
168
+ # would take down every later control op and the PTY itself.
169
+ ans({"ok": False, "error": str(error)})
164
170
  elif op == "foreground":
165
171
  pgrp = self.foreground_pgrp()
166
172
  ans({"ok": True, "pgrp": pgrp, "shellPgrp": self.shell_pgrp})
@@ -196,7 +202,11 @@ class Bridge:
196
202
  break
197
203
  deadline = time.monotonic() + wait
198
204
  while time.monotonic() < deadline:
199
- done, _ = os.waitpid(self.pid, os.WNOHANG)
205
+ try:
206
+ done, _ = os.waitpid(self.pid, os.WNOHANG)
207
+ except ChildProcessError:
208
+ # The pump loop already reaped the child: session gone.
209
+ return True
200
210
  if done == self.pid:
201
211
  return True
202
212
  time.sleep(0.05)
package/lib/wsl/world.js CHANGED
@@ -176,16 +176,11 @@ export function hostExecutable(name) {
176
176
  * @returns {NodeJS.ProcessEnv} the environment for the `wsl.exe` process.
177
177
  */
178
178
  function bridgeEnv(extra) {
179
- const env = { ...process.env, ...ENV_OVERRIDES, ...extra }
180
- const flags = []
181
- for (const [key, value] of Object.entries(extra ?? {})) {
182
- if (key.toUpperCase() === 'WSLENV') continue
183
- flags.push(isWindowsPathShaped(value) ? `${key}/p` : key)
184
- }
185
- const ambient = process.env.WSLENV
186
- const merged = [ambient, flags.join(':')].filter((part) => part !== undefined && part !== '').join(':')
187
- if (merged !== '') env.WSLENV = merged
188
- return env
179
+ // Flag-building is shared with withWslEnvFlags so the WSLENV metacharacter
180
+ // filter cannot drift apart between the two call sites again (bridgeEnv
181
+ // used to accept `:`/`/`-bearing keys that withWslEnvFlags rejects). env is
182
+ // optional — the common probe path passes none.
183
+ return { ...process.env, ...ENV_OVERRIDES, ...withWslEnvFlags(extra ?? {}) }
189
184
  }
190
185
 
191
186
  /**
@@ -404,7 +399,7 @@ export async function resolveDistroHome(distro, username) {
404
399
  let user = typeof username === 'string' ? username.trim() : ''
405
400
  if (user === '') {
406
401
  // Non-login + trimmed on purpose: the parsed answer must be provably this
407
- // probe's output ('zcluo'), never profile scripts'.
402
+ // probe's output (the login name), never profile scripts'.
408
403
  const who = await runWslShell({ distro, linuxCwd: '/', command: 'id -un', loginShell: false, timeoutMs: 30_000 })
409
404
  user = who.stdout.trim()
410
405
  if (user === '' || user.includes('\n')) {
package/package.json CHANGED
@@ -1,35 +1,37 @@
1
- {
2
- "name": "dsh-wsl-desktop",
3
- "version": "0.2.0",
4
- "type": "module",
5
- "main": "lib/index.js",
6
- "files": [
7
- "lib",
8
- "cordis.patch.yml",
9
- "README.md",
10
- "LICENSE"
11
- ],
12
- "dependencies": null,
13
- "dsh": {
14
- "bundle": {
15
- "patch": "./cordis.patch.yml"
16
- },
17
- "client": {
18
- "platform": "web",
19
- "inject": [
20
- "@deepseek-ai/dsh-api-workspace-controller",
21
- "@deepseek-ai/dsh-client-ui-workspace"
22
- ]
23
- }
24
- },
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/zcluo/dsh-wsl-desktop.git"
28
- },
29
- "publishConfig": {
30
- "access": "public"
31
- },
32
- "manifestVersion": 1,
33
- "license": "MIT",
34
- "description": "DSH Desktop WSL execution world: add a WSL workspace and run every tool inside the distribution."
35
- }
1
+ {
2
+ "name": "dsh-wsl-desktop",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "main": "lib/index.js",
6
+ "engines": {
7
+ "node": ">=22.19"
8
+ },
9
+ "files": [
10
+ "lib",
11
+ "cordis.patch.yml",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "dsh": {
16
+ "bundle": {
17
+ "patch": "./cordis.patch.yml"
18
+ },
19
+ "client": {
20
+ "platform": "web",
21
+ "inject": [
22
+ "@deepseek-ai/dsh-api-workspace-controller",
23
+ "@deepseek-ai/dsh-client-ui-workspace"
24
+ ]
25
+ }
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/zcluo/dsh-wsl-desktop.git"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "manifestVersion": 1,
35
+ "license": "MIT",
36
+ "description": "DSH Desktop WSL execution world: add a WSL workspace and run every tool inside the distribution."
37
+ }