dsh-wsl-desktop 0.7.6 → 0.8.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.en.md CHANGED
@@ -86,6 +86,14 @@ A few details you will run into:
86
86
  this plugin does not override it.
87
87
  - Inside an open WSL workspace, the file list and the terminal are the distro's. PowerShell in Windows
88
88
  workspaces is unaffected.
89
+ - **The W button has its own shortcut, and its hint follows a rebind.** The Windows desktop default is
90
+ `Ctrl+Alt+W` (the web profile declares none: the sidebar page-close command's web binding is the
91
+ same combination, and registration validates every profile's defaults, so declaring it would refuse
92
+ the command on the desktop too); hovering or focusing W shows the action name and keycaps the same way the
93
+ adjacent "+" does. The command is registered in Keyboard shortcuts, so rebinding or clearing it
94
+ updates the keycaps and `aria-keyshortcuts` immediately. When another command already owns the
95
+ default combination and registration is refused, the W button still works and merely omits the
96
+ keycaps; the shortcut service itself is a desktop dependency present since 0.1.7.
89
97
 
90
98
  ## What will surprise you
91
99
 
@@ -125,10 +133,19 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
125
133
  target is invalidated by entry changes only** — adds, removes and renames are seen, a content change
126
134
  of a file inside it is not; watch that file's own path to follow content. Mechanisms and
127
135
  measurements are in [WSL file watching](docs/WATCH.md).
136
+ - **Confined mode needs passwordless sudo; without it there is no fence, and it fails loudly.** The
137
+ default permission mode is `workspace-write`, which has to build a mount namespace inside the
138
+ distro — and the WSL kernel does not allow bind mounts in a user namespace, so `sudo -n` is the only
139
+ way. Without that grant a confined command **fails outright** (with a structured error code, never a
140
+ silent unfenced run). Two ways out: give the session user NOPASSWD sudo, or switch the permission
141
+ mode to full access, which needs no sudo at all. See
142
+ [Linux-side confinement](docs/CONFINEMENT.md).
128
143
  - **A desktop update can break the plugin.** The plugin depends on the desktop framework's APIs — the
129
144
  harness — and changes to them interrupt it (0.1.6 → 0.1.7 broke four places in one go). So after
130
- updating the desktop, run `node scripts/verify-post-restart.mjs` once and only keep going when
131
- everything is green.
145
+ updating the desktop, open the W dialog once and confirm it lists your distributions, that
146
+ `bootstrap` reports the `pluginVersion` you just installed, and that `capabilities.status` is not
147
+ `degraded`; if any of those is wrong, take the desktop and the plugin back to a matching pair — the
148
+ order is in [Compatibility and upgrades](docs/COMPATIBILITY.md).
132
149
  - **Failures now carry a code, so you can diagnose first.** The browser receives a stable code and
133
150
  the whitelisted params, never the host message, stderr or resolved paths; after the user is
134
151
  confirmed, the W dialog shows an advisory health check whose failed rows carry the fix. Codes,
@@ -156,9 +173,10 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
156
173
  - [Engineering notes](docs/ENGINEERING-NOTES.md) — measured constraints, repository layout, release flow, install shape
157
174
  - [Distro support matrix](docs/DISTRO-SUPPORT.md) — which distros were verified, and what each one needs
158
175
  - [Compatibility and upgrades](docs/COMPATIBILITY.md) — plugin/desktop version relation, upgrade order, rollback steps
159
- - [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the two proposals to the harness upstream
176
+ - [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the proposals to the harness upstream, including the record that an execution-world plugin cannot declare its session's initial permission posture
160
177
  - [Upstream defect reports](issues/README.md) — the harness issues filed, their measured evidence, and how the local workarounds relate
161
178
  - [Architecture and flow diagrams](docs/diagrams.md) — interactive architecture, sequence and lifecycle diagrams
179
+ - [Real-machine acceptance runbook](docs/ACCEPTANCE-RUNBOOK.md) — the checks that only run on Windows + WSL2, their expected output, and the misjudgement list (Chinese only)
162
180
 
163
181
  Three of the linked documents — the distro support matrix, the upstream proposals and the diagram
164
182
  index — are **Chinese-only**, because no English text for them exists. Translating them is a separate
package/README.md CHANGED
@@ -72,6 +72,11 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
72
72
  表示网络/子系统路径的写法,形如 `\\wsl.localhost\<发行版>\home\...`。
73
73
  - 自带的「+」保持部署原本的行为(桌面上是 Electron 的目录选择器),本插件不改写它。
74
74
  - 已经打开的 WSL 工作区里,文件列表和终端都是发行版内的。Windows 工作区里的 PowerShell 不受影响。
75
+ - **W 按钮有自己的快捷键,提示会跟着改绑走。** Windows 桌面的默认是 `Ctrl+Alt+W`(Web 端不声明默认:
76
+ 侧栏页面关闭(`page.close`)的 Web 组合也是它,而快捷键注册会校验所有平台的默认值,声明它会让命令在桌面端也注册失败);
77
+ 悬停或聚焦 W 时,提示会和左边的「+」一样显示动作名与键帽。命令登记在「快捷键」设置里,
78
+ 改绑或清除后键帽与 `aria-keyshortcuts` 随即更新。默认组合被别的命令占用、导致注册被拒时,
79
+ W 按钮照常工作,只是不显示键帽;快捷键服务本身是 0.1.7 起桌面自带的依赖。
75
80
 
76
81
  ## 会意外的地方
77
82
 
@@ -102,9 +107,16 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
102
107
  文件每 300ms `stat` 一次。**目录目标只对条目变化生效**——新增、删除、改名可见,目录里某个文件的
103
108
  内容变化不可见;要盯内容就按文件路径监听它。机制与实测见
104
109
  [WSL 文件变更监听](docs/WATCH.md)。
110
+ - **受限模式要免密 sudo;没有就当不了围栏,而且会明确失败。** 默认权限模式是
111
+ `workspace-write`,它要求在发行版里搭 mount namespace,而 WSL 内核不允许在 user namespace
112
+ 里做 bind mount,所以只能用 `sudo -n`。缺这份授权时受限命令**直接失败**(结构化错误码,
113
+ 不是静默裸跑)。两条出路:给会话用户配 NOPASSWD sudo,或者把权限模式切到「完全权限」——
114
+ 后者不需要任何 sudo。详见 [Linux 侧约束](docs/CONFINEMENT.md)。
105
115
  - **桌面更新后插件可能失效。** 插件依赖桌面内部的框架(harness)的 API,这些 API 变化会打断它
106
- (0.1.6 → 0.1.7 一次断了四处)。所以更新桌面之后先跑一次
107
- `node scripts/verify-post-restart.mjs`,全绿再继续用。
116
+ (0.1.6 → 0.1.7 一次断了四处)。所以更新桌面之后,先打开一次 W 对话框确认它能列出你的
117
+ 发行版、`bootstrap` 里的 `pluginVersion` 是你刚装的版本、`capabilities.status` 不是
118
+ `degraded`;任何一项不对就把桌面和插件退回彼此匹配的版本,
119
+ 顺序见 [兼容性与升级](docs/COMPATIBILITY.md)。
108
120
  - **失败现在带 code,可以先自己诊断。** 浏览器侧的错误只回稳定 code 与允许的 params,
109
121
  宿主 message、stderr 与解析后的路径不会过线;W 对话框在确认用户后会显示一条建议性的
110
122
  「环境检查」,失败项带修复命令。错误码、检查项与降级行为见 [诊断](docs/DIAGNOSTICS.md)。
@@ -129,9 +141,10 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
129
141
  - [工程笔记](docs/ENGINEERING-NOTES.md) — 实测约束、仓库布局、发版流程、安装形态
130
142
  - [发行版支持矩阵](docs/DISTRO-SUPPORT.md) — 哪些发行版验证过,每种缺什么
131
143
  - [兼容性与升级](docs/COMPATIBILITY.md) — 插件与桌面的版本关系、升级顺序、回退步骤
132
- - [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的两项提案
144
+ - [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的提案(含执行世界类插件无法声明初始权限姿态的记录)
133
145
  - [上游缺陷记录](issues/README.md) — 已上报 harness 的问题、实测证据与本地规避的关系
134
146
  - [架构与流程图](docs/diagrams.md) — 可交互的架构、时序与生命周期图
147
+ - [真实机器验收手册](docs/ACCEPTANCE-RUNBOOK.md) — 只能在 Windows + WSL2 上跑的检查项、期望输出与误判清单
135
148
 
136
149
  其中三份——发行版支持矩阵、上游 API 提案、架构与流程图索引——**只有中文版**,因为目前没有对应的
137
150
  英文文本。把它们翻成英文是一件独立的工作,不是改个文件名。
package/lib/client.js CHANGED
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * Adds one control beside the shipped "add workspace" button in the sidebar
5
5
  * section header: a `W` button opening this plugin's WSL workspace picker.
6
+ * The button carries the same kind of shortcut hint the shipped trigger does:
7
+ * its tooltip shows the effective keys from the shell's shortcut catalog, and
8
+ * the registered command opens the picker from anywhere the page owns the
9
+ * keyboard.
6
10
  *
7
11
  * The shipped button keeps its own behaviour: this half registers no slot
8
12
  * occupant, because `sidebar.workspaces.directoryFlow` is `kind: 'single'` and
@@ -71,6 +75,28 @@ window.__ModuleLoader__.load({
71
75
  /** Sprite symbol id shape, as `iconSymbolId` composes it: `dsw-icon-<glyph>-<weight>`. */
72
76
  const SPRITE_ICON_ID = /^#?dsw-icon-(.+)-(?:regular|medium)$/
73
77
 
78
+ /**
79
+ * Stable command identity of the W button's keyboard action.
80
+ *
81
+ * The id is the plugin's public shortcut identity: it travels through the
82
+ * shell's command catalog, appears in the Keyboard shortcuts reference, and
83
+ * survives user rebinds. Renaming it orphans every saved override, so it
84
+ * names the action rather than the button's ever-changing presentation.
85
+ */
86
+ const SHORTCUT_ID = 'wsl.workspace.open'
87
+
88
+ /**
89
+ * The add-workspace trigger's own tooltip delay, mirrored so the two
90
+ * controls beside each other reveal their shortcut hints together.
91
+ */
92
+ const TOOLTIP_HOVER_DELAY_MS = 500
93
+
94
+ /** Gap between the W button and its shortcut tooltip, in CSS pixels. */
95
+ const TOOLTIP_GAP = 8
96
+
97
+ /** Minimum distance the shortcut tooltip keeps from the viewport edges. */
98
+ const TOOLTIP_MARGIN = 12
99
+
74
100
  /**
75
101
  * The row glyphs whose artwork is FILL-only, as sprite names.
76
102
  *
@@ -336,9 +362,24 @@ window.__ModuleLoader__.load({
336
362
  function routeFailure(method, status, envelope) {
337
363
  const error = new Error(envelope?.error ?? `${method} failed (HTTP ${status})`)
338
364
  error.code = typeof envelope?.code === 'string' ? envelope.code : status === 0 ? 'transport' : 'internal'
339
- error.params = envelope !== null && typeof envelope?.params === 'object' && envelope?.params !== null
340
- ? envelope.params
341
- : {}
365
+ // A refusal the route never answered carries no envelope — the transport
366
+ // fence answers an empty-bodied 401/403, and the webserver's own admission
367
+ // answers 401/403/503 the same way. Without the status this collapsed to a
368
+ // bare `internal` with empty params, which is what a real GUI check hit on
369
+ // Desktop 0.2.2-rc.3: every method failed and the diagnostic said nothing.
370
+ // The status is the ONE fact that separates "not authenticated" from
371
+ // "not permitted" from "host has no admission provider", so it is carried
372
+ // in the params the copyable report prints. It is produced locally, so it
373
+ // is not subject to the wire param allowlist.
374
+ const carried = envelope !== null && typeof envelope?.params === 'object' && envelope?.params !== null
375
+ if (carried) {
376
+ error.params = envelope.params
377
+ } else {
378
+ // No envelope means the route never answered in its own vocabulary; the
379
+ // HTTP status is then the only fact that separates "not authenticated"
380
+ // from "not permitted" from "the host has no admission provider".
381
+ error.params = status === 0 ? {} : { status }
382
+ }
342
383
  return error
343
384
  }
344
385
 
@@ -1518,7 +1559,15 @@ window.__ModuleLoader__.load({
1518
1559
 
1519
1560
  /** The injected services the picker and its trigger read. */
1520
1561
  // ── 5. the companion button and workspace rows ─────────────────────────────
1521
- const companion = { button: null, fallback: null, trigger: null, observer: null, scheduled: false, disposed: false }
1562
+ const companion = {
1563
+ button: null,
1564
+ fallback: null,
1565
+ hideHint: null,
1566
+ trigger: null,
1567
+ observer: null,
1568
+ scheduled: false,
1569
+ disposed: false,
1570
+ }
1522
1571
 
1523
1572
  /** Whether the missing-workspace-source warning was already printed. */
1524
1573
  let warnedWorkspaceSource = false
@@ -1876,6 +1925,10 @@ window.__ModuleLoader__.load({
1876
1925
  companion.trigger = trigger
1877
1926
  }
1878
1927
  if (trigger === null) {
1928
+ // React took the anchor away, so the companion leaves too. The tooltip
1929
+ // is a fixed portal with no pointer to leave; withdraw it explicitly or
1930
+ // it outlives the control it describes.
1931
+ companion.hideHint?.()
1879
1932
  if (button.isConnected) button.remove()
1880
1933
  return
1881
1934
  }
@@ -1896,7 +1949,13 @@ window.__ModuleLoader__.load({
1896
1949
  const box = button.getBoundingClientRect()
1897
1950
  const fits = row === undefined || box.right <= row.right + 1
1898
1951
  const triggerVisible = getComputedStyle(trigger).visibility !== 'hidden'
1899
- button.style.display = fits && triggerVisible ? '' : 'none'
1952
+ const shown = fits && triggerVisible
1953
+ button.style.display = shown ? '' : 'none'
1954
+ // The shipped cluster hides itself (inline search, narrow rail) by
1955
+ // visibility and the companion follows via display; a visible tooltip must
1956
+ // follow too, because no pointerleave fires for a control that left the
1957
+ // hit-test tree.
1958
+ if (!shown) companion.hideHint?.()
1900
1959
  }
1901
1960
 
1902
1961
  /**
@@ -1911,6 +1970,362 @@ window.__ModuleLoader__.load({
1911
1970
  return document.body ?? document.documentElement
1912
1971
  }
1913
1972
 
1973
+ /**
1974
+ * The effective catalog row for the W shortcut, or null when the shell has
1975
+ * no shortcut service or has not published the command yet.
1976
+ *
1977
+ * The row is the SINGLE source of the hint: `keys` are the platform-formatted
1978
+ * labels the shortcut service presents, and `aria` is the same binding in
1979
+ * `aria-keyshortcuts` spelling. A rebind replaces the row, so the tooltip
1980
+ * follows the user's setting instead of the default first seen.
1981
+ * @param {object} ctx - the client plugin context.
1982
+ * @returns {object | null} the row for `SHORTCUT_ID`, or null.
1983
+ */
1984
+ function shortcutRowOf(ctx) {
1985
+ const catalog = ctx.shortcuts?.catalog
1986
+ if (typeof catalog?.getSnapshot !== 'function') return null
1987
+ const rows = catalog.getSnapshot()
1988
+ if (!Array.isArray(rows)) return null
1989
+ return rows.find((row) => row.id === SHORTCUT_ID) ?? null
1990
+ }
1991
+
1992
+ /**
1993
+ * Publish or withdraw the accessible shortcut claim on one control.
1994
+ * @param {HTMLElement | null} node - the control, or null when it is absent.
1995
+ * @param {object | null} row - the effective catalog row.
1996
+ */
1997
+ function setKeyshortcuts(node, row) {
1998
+ if (node === null) return
1999
+ if (typeof row?.aria === 'string' && row.aria !== '') node.setAttribute('aria-keyshortcuts', row.aria)
2000
+ else node.removeAttribute('aria-keyshortcuts')
2001
+ }
2002
+
2003
+ /** Tooltip bubble styling, mirrored from the shipped tooltip's semantic tokens. */
2004
+ const SHORTCUT_TOOLTIP_STYLE = {
2005
+ position: 'fixed',
2006
+ zIndex: '1100',
2007
+ display: 'none',
2008
+ alignItems: 'center',
2009
+ gap: '8px',
2010
+ width: 'max-content',
2011
+ maxWidth: 'min(50vw, calc(100vw - 24px))',
2012
+ boxSizing: 'border-box',
2013
+ padding: '3px 7px',
2014
+ borderRadius: 'var(--dsw-radius-sm, 8px)',
2015
+ background: 'var(--dsw-alias-tooltip-bg, #3a3a3a)',
2016
+ color: 'var(--dsw-static-neutral-bluish-00, #ffffff)',
2017
+ fontSize: '13px',
2018
+ lineHeight: '20px',
2019
+ whiteSpace: 'pre-line',
2020
+ overflowWrap: 'break-word',
2021
+ pointerEvents: 'none',
2022
+ }
2023
+
2024
+ /** The keycap strip's layout inside a tooltip. */
2025
+ const SHORTCUT_KEYS_STYLE = {
2026
+ display: 'inline-flex',
2027
+ flex: 'none',
2028
+ alignItems: 'center',
2029
+ gap: '2px',
2030
+ color: 'inherit',
2031
+ fontSize: '11px',
2032
+ lineHeight: '14px',
2033
+ whiteSpace: 'nowrap',
2034
+ }
2035
+
2036
+ /** One keycap: the shipped tooltip's 16px badge. */
2037
+ const SHORTCUT_KEY_STYLE = {
2038
+ boxSizing: 'border-box',
2039
+ display: 'inline-flex',
2040
+ alignItems: 'center',
2041
+ justifyContent: 'center',
2042
+ font: 'inherit',
2043
+ }
2044
+
2045
+ /**
2046
+ * One shortcut tooltip attached to a plain DOM control.
2047
+ *
2048
+ * The shipped "add workspace" button gets this from the React Tooltip in
2049
+ * `ui-primitives`; this half is a hand-written factory with no module graph,
2050
+ * and a feature plugin may not reach into another feature's values, so the
2051
+ * same presentation is rebuilt here on the shared semantic tokens. The
2052
+ * bubble is portaled to the body and positioned fixed, so the sidebar's
2053
+ * clipping and stacking contexts cannot hide it.
2054
+ * @param {HTMLElement} anchor - the control the tooltip belongs to.
2055
+ * @param {string} label - the action name shown before the keycaps.
2056
+ * @returns {{ setKeys: (keys: readonly string[]) => void, dismiss: () => void, dispose: () => void }}
2057
+ * the key publisher, the layout-driven dismissal, and the disposer.
2058
+ */
2059
+ function createShortcutHint(anchor, label) {
2060
+ let bubble = null
2061
+ let keys = []
2062
+ let showTimer = null
2063
+ let visible = false
2064
+ // The shipped Tooltip keeps hover and focus as INDEPENDENT triggers and
2065
+ // withdraws the bubble only after BOTH clear: hovering away from a focused
2066
+ // anchor must not drop it, and blurring while the pointer is still over
2067
+ // the anchor must not drop it either.
2068
+ let hovered = false
2069
+ let focused = false
2070
+
2071
+ function clearShowTimer() {
2072
+ if (showTimer === null) return
2073
+ clearTimeout(showTimer)
2074
+ showTimer = null
2075
+ }
2076
+
2077
+ /**
2078
+ * Render the current keycap labels into the bubble, or hide the strip.
2079
+ *
2080
+ * Windows presents a combination as one plus-separated run in a single
2081
+ * pill; a modifier set without a separator (macOS symbols) gets one cap
2082
+ * per key. Both shapes are built from the catalog's labels, never from a
2083
+ * key spelling written in this file.
2084
+ */
2085
+ function renderKeys() {
2086
+ if (bubble === null) return
2087
+ const strip = bubble.querySelector('[data-wsl-tooltip-keys]')
2088
+ if (strip === null) return
2089
+ strip.replaceChildren()
2090
+ if (keys.length === 0) {
2091
+ strip.style.display = 'none'
2092
+ bubble.style.paddingInlineEnd = '7px'
2093
+ return
2094
+ }
2095
+ strip.style.display = 'inline-flex'
2096
+ bubble.style.paddingInlineEnd = '5px'
2097
+ const joined = keys.includes('+')
2098
+ strip.style.background = joined
2099
+ ? 'var(--dsw-alias-tooltip-key-bg, rgba(255,255,255,0.18))'
2100
+ : 'transparent'
2101
+ strip.style.height = joined ? '16px' : ''
2102
+ strip.style.padding = joined ? '0 4px' : '0'
2103
+ strip.style.borderRadius = joined ? '4px' : ''
2104
+ for (const token of keys) {
2105
+ const key = document.createElement('kbd')
2106
+ key.textContent = token
2107
+ Object.assign(key.style, SHORTCUT_KEY_STYLE)
2108
+ if (!joined) {
2109
+ key.style.minWidth = '16px'
2110
+ key.style.height = '16px'
2111
+ key.style.padding = '0 2px'
2112
+ key.style.borderRadius = '4px'
2113
+ key.style.background = 'var(--dsw-alias-tooltip-key-bg, rgba(255,255,255,0.18))'
2114
+ }
2115
+ strip.append(key)
2116
+ }
2117
+ }
2118
+
2119
+ /** Build the bubble once: a label span and a keycap strip. */
2120
+ function ensureBubble() {
2121
+ if (bubble !== null) return bubble
2122
+ bubble = el('span', SHORTCUT_TOOLTIP_STYLE)
2123
+ bubble.setAttribute('role', 'tooltip')
2124
+ bubble.setAttribute('aria-hidden', 'true')
2125
+ bubble.setAttribute('data-wsl-shortcut-tooltip', '')
2126
+ const text = el('span', { minWidth: '0' }, label)
2127
+ text.setAttribute('data-wsl-tooltip-label', '')
2128
+ const strip = el('span', SHORTCUT_KEYS_STYLE)
2129
+ strip.setAttribute('data-wsl-tooltip-keys', '')
2130
+ bubble.append(text, strip)
2131
+ renderKeys()
2132
+ return bubble
2133
+ }
2134
+
2135
+ /** Place the bubble below the anchor, clamped to the viewport. */
2136
+ function position() {
2137
+ if (bubble === null) return
2138
+ const anchorBox = anchor.getBoundingClientRect()
2139
+ // A media query or an ancestor class can collapse the header on resize
2140
+ // without any DOM mutation, so no layout pass reaches the hint. A zero
2141
+ // box means the tooltip has no anchor left to describe: withdraw it
2142
+ // instead of placing it from (0, 0).
2143
+ if (anchorBox.width <= 0 && anchorBox.height <= 0) {
2144
+ hide()
2145
+ return
2146
+ }
2147
+ const bubbleBox = bubble.getBoundingClientRect()
2148
+ const centered = anchorBox.left + anchorBox.width / 2 - bubbleBox.width / 2
2149
+ const left = Math.min(
2150
+ Math.max(TOOLTIP_MARGIN, centered),
2151
+ Math.max(TOOLTIP_MARGIN, window.innerWidth - bubbleBox.width - TOOLTIP_MARGIN),
2152
+ )
2153
+ let top = anchorBox.bottom + TOOLTIP_GAP
2154
+ // The sidebar header sits near the top edge, but a short window or a
2155
+ // moved trigger still has to get a readable bubble: flip above the
2156
+ // anchor when the space below cannot hold it.
2157
+ if (top + bubbleBox.height > window.innerHeight - TOOLTIP_MARGIN) {
2158
+ top = Math.max(TOOLTIP_MARGIN, anchorBox.top - bubbleBox.height - TOOLTIP_GAP)
2159
+ }
2160
+ bubble.style.left = `${left}px`
2161
+ bubble.style.top = `${top}px`
2162
+ bubble.style.visibility = 'visible'
2163
+ }
2164
+
2165
+ function show() {
2166
+ if (!anchor.isConnected || anchor.style.display === 'none') return
2167
+ // Closing the picker refocuses its opener. When that opener already sits
2168
+ // in a hidden subtree its box is zero; placing a fixed bubble from that
2169
+ // box would paint it at the viewport origin.
2170
+ const anchorBox = anchor.getBoundingClientRect()
2171
+ if (anchorBox.width <= 0 && anchorBox.height <= 0) return
2172
+ const node = ensureBubble()
2173
+ if (!node.isConnected) hostElement().append(node)
2174
+ visible = true
2175
+ node.style.display = 'inline-flex'
2176
+ node.style.visibility = 'hidden'
2177
+ position()
2178
+ }
2179
+
2180
+ function hide() {
2181
+ clearShowTimer()
2182
+ visible = false
2183
+ if (bubble !== null) bubble.style.display = 'none'
2184
+ }
2185
+
2186
+ /**
2187
+ * Withdraw the bubble AND clear both triggers.
2188
+ *
2189
+ * The layout pass calls this when the anchor leaves the layout, mirroring
2190
+ * the shipped Tooltip's disabled effect: without the reset, a hover that
2191
+ * was live when the header hid survives the withdrawal, and a later
2192
+ * focus/blur can keep a bubble on screen with no pointer over the anchor.
2193
+ */
2194
+ function dismiss() {
2195
+ hovered = false
2196
+ focused = false
2197
+ hide()
2198
+ }
2199
+
2200
+ function reveal(delay) {
2201
+ clearShowTimer()
2202
+ if (delay <= 0) {
2203
+ show()
2204
+ return
2205
+ }
2206
+ showTimer = setTimeout(() => {
2207
+ showTimer = null
2208
+ show()
2209
+ }, delay)
2210
+ }
2211
+
2212
+ const onPointerEnter = (event) => {
2213
+ // Touch has no hover state; tapping still invokes the button.
2214
+ if (event.pointerType === 'touch') return
2215
+ hovered = true
2216
+ reveal(TOOLTIP_HOVER_DELAY_MS)
2217
+ }
2218
+ const onPointerLeave = () => {
2219
+ hovered = false
2220
+ // A pending hover reveal is cancelled even when focus holds the bubble
2221
+ // open; otherwise it would reappear after the pointer has left.
2222
+ clearShowTimer()
2223
+ if (!focused) hide()
2224
+ }
2225
+ const onFocus = () => {
2226
+ // The shared input-modality attribute is how the shell publishes
2227
+ // pointer-acquired focus; a picker closing back onto its trigger must
2228
+ // not flash the shortcut bubble after a click.
2229
+ if (document.documentElement.getAttribute('data-input-modality') === 'pointer') return
2230
+ focused = true
2231
+ reveal(0)
2232
+ }
2233
+ const onBlur = () => {
2234
+ focused = false
2235
+ clearShowTimer()
2236
+ if (!hovered) hide()
2237
+ }
2238
+ const onClick = () => {
2239
+ // Activating the button dismisses the bubble even though the pointer
2240
+ // stays over it; the shipped tooltip clears its focus trigger here too.
2241
+ focused = false
2242
+ hide()
2243
+ }
2244
+ const onResize = () => { if (visible) position() }
2245
+ anchor.addEventListener('pointerenter', onPointerEnter)
2246
+ anchor.addEventListener('pointerleave', onPointerLeave)
2247
+ anchor.addEventListener('focus', onFocus)
2248
+ anchor.addEventListener('blur', onBlur)
2249
+ anchor.addEventListener('click', onClick)
2250
+ window.addEventListener('resize', onResize)
2251
+
2252
+ return {
2253
+ /**
2254
+ * Publish the effective keys to the (possibly not yet built) bubble.
2255
+ * @param {readonly string[]} nextKeys - the catalog's presented labels.
2256
+ */
2257
+ setKeys(nextKeys) {
2258
+ const normalized = Array.isArray(nextKeys) ? nextKeys.map(String) : []
2259
+ if (normalized.join('\u0000') === keys.join('\u0000')) return
2260
+ keys = normalized
2261
+ if (bubble === null) return
2262
+ renderKeys()
2263
+ if (visible) position()
2264
+ },
2265
+ /** Withdraw the bubble and clear both triggers when its anchor leaves the layout. */
2266
+ dismiss,
2267
+ /** Release the bubble and every listener it installed. */
2268
+ dispose() {
2269
+ clearShowTimer()
2270
+ visible = false
2271
+ hovered = false
2272
+ focused = false
2273
+ anchor.removeEventListener('pointerenter', onPointerEnter)
2274
+ anchor.removeEventListener('pointerleave', onPointerLeave)
2275
+ anchor.removeEventListener('focus', onFocus)
2276
+ anchor.removeEventListener('blur', onBlur)
2277
+ anchor.removeEventListener('click', onClick)
2278
+ window.removeEventListener('resize', onResize)
2279
+ if (bubble !== null) {
2280
+ bubble.remove()
2281
+ bubble = null
2282
+ }
2283
+ keys = []
2284
+ },
2285
+ }
2286
+ }
2287
+
2288
+ /**
2289
+ * Register the W button's keyboard command, tolerating a host without the
2290
+ * shortcut service or a catalog that refuses the default.
2291
+ *
2292
+ * Registration is deliberately outside the button's click path: the command
2293
+ * resolves to the same `openPicker` the click calls, so the two entries cannot
2294
+ * drift. A refused registration (another command already owns the same
2295
+ * default on one of this command's profiles) is logged and leaves the
2296
+ * button usable; the hint then carries the label alone.
2297
+ * @param {object} ctx - the client plugin context.
2298
+ * @returns {() => void} the registration's disposer, or a no-op.
2299
+ */
2300
+ function installShortcut(ctx) {
2301
+ try {
2302
+ // The access itself is inside the guard: a context whose service getter
2303
+ // throws must degrade the hint, not abort the install that owns W.
2304
+ const shortcuts = ctx.shortcuts
2305
+ if (typeof shortcuts?.register !== 'function') return () => {}
2306
+ return shortcuts.register({
2307
+ id: SHORTCUT_ID,
2308
+ label: () => BUTTON_LABEL,
2309
+ aliases: ['wsl', 'wsl workspace', 'open wsl workspace'],
2310
+ defaults: {
2311
+ // Desktop only. On the Web profile the shell's page.close command
2312
+ // already owns KeyW+primary+alt, and the shortcut registry
2313
+ // validates EVERY profile's defaults at registration:
2314
+ // declaring the Web default here makes this command throw even on
2315
+ // a desktop runtime, so the hint loses its keys and the key does
2316
+ // nothing. Web users can bind the command themselves.
2317
+ 'desktop:windows': { code: 'KeyW', modifiers: ['primary', 'alt'] },
2318
+ },
2319
+ regions: ['page', 'editable'],
2320
+ modals: ['sidebar'],
2321
+ resolve: () => ({ status: 'handled', run: () => openPicker(ctx) }),
2322
+ })
2323
+ } catch (error) {
2324
+ console.warn('dsh-wsl-desktop: W 快捷键注册失败,W 按钮仍然可用', error)
2325
+ return () => {}
2326
+ }
2327
+ }
2328
+
1914
2329
  /**
1915
2330
  * Install the companion button and keep it attached.
1916
2331
  * @param {object} ctx - the client plugin context.
@@ -1923,19 +2338,22 @@ window.__ModuleLoader__.load({
1923
2338
  button.setAttribute('aria-label', BUTTON_LABEL)
1924
2339
  button.setAttribute('aria-haspopup', 'dialog')
1925
2340
  button.setAttribute('aria-expanded', 'false')
1926
- button.title = BUTTON_LABEL
1927
2341
  button.append(el('span', {
1928
2342
  fontWeight: '600',
1929
2343
  fontSize: '13px',
1930
2344
  lineHeight: '1',
1931
2345
  letterSpacing: '0.02em',
1932
2346
  }, 'W'))
2347
+ // The hint replaces the native title: it carries the SAME action name plus
2348
+ // the effective keycaps, and a title would show a second, stale tooltip.
2349
+ const hint = createShortcutHint(button, BUTTON_LABEL)
1933
2350
  button.addEventListener('click', (event) => {
1934
2351
  event.preventDefault()
1935
2352
  event.stopPropagation()
1936
2353
  openPicker(ctx)
1937
2354
  })
1938
2355
  companion.button = button
2356
+ companion.hideHint = hint.dismiss
1939
2357
 
1940
2358
  /** The degraded entry shown when the sidebar anchor is missing. */
1941
2359
  let fallbackEntry = null
@@ -1994,7 +2412,45 @@ window.__ModuleLoader__.load({
1994
2412
  hostElement().append(entry)
1995
2413
  fallbackEntry = entry
1996
2414
  companion.fallback = open
2415
+ // The fallback is the W button's only visible entry in this phase, so
2416
+ // the shortcut claim belongs on it too; the visual bubble stays with the
2417
+ // real button to keep the failure path minimal.
2418
+ applyShortcut()
2419
+ }
2420
+
2421
+ /**
2422
+ * Publish the effective shortcut row to the button, hint, and fallback.
2423
+ *
2424
+ * Called once at install and again for every catalog change (a rebind, a
2425
+ * clear, another command's registration). The row is read from the live
2426
+ * catalog rather than remembered, so a rebind cannot leave the hint
2427
+ * claiming keys the command no longer owns.
2428
+ */
2429
+ function applyShortcut() {
2430
+ let row = null
2431
+ try {
2432
+ row = shortcutRowOf(ctx)
2433
+ } catch (error) {
2434
+ // A throwing catalog is a shell fault, not a reason to lose the W
2435
+ // button: the hint is peripheral and degrades to its label.
2436
+ console.warn('dsh-wsl-desktop: W 快捷键目录读取失败,W 按钮仍然可用', error)
2437
+ }
2438
+ setKeyshortcuts(button, row)
2439
+ hint.setKeys(row?.keys ?? [])
2440
+ setKeyshortcuts(fallbackEntry?.querySelector('[data-wsl-fallback-entry]') ?? null, row)
2441
+ }
2442
+
2443
+ const disposeShortcut = installShortcut(ctx)
2444
+ let disposeCatalog = null
2445
+ try {
2446
+ const catalog = ctx.shortcuts?.catalog
2447
+ disposeCatalog = typeof catalog?.subscribe === 'function'
2448
+ ? catalog.subscribe(applyShortcut)
2449
+ : null
2450
+ } catch (error) {
2451
+ console.warn('dsh-wsl-desktop: W 快捷键目录订阅失败,W 按钮仍然可用', error)
1997
2452
  }
2453
+ applyShortcut()
1998
2454
 
1999
2455
  // `sync` is idempotent and never removes the node it manages, so its own
2000
2456
  // work cannot re-trigger the observer it runs from.
@@ -2049,6 +2505,12 @@ window.__ModuleLoader__.load({
2049
2505
  return () => {
2050
2506
  clearTimeout(warning)
2051
2507
  removeFallback()
2508
+ // Unsubscribe BEFORE the command leaves the catalog: removing it makes
2509
+ // the service publish a fresh snapshot, and a listener still attached
2510
+ // would rebuild the bubble this teardown is about to delete.
2511
+ if (disposeCatalog !== null) disposeCatalog()
2512
+ disposeShortcut()
2513
+ hint.dispose()
2052
2514
  observer.disconnect()
2053
2515
  unsubscribe?.()
2054
2516
  // disconnect() does not cancel a callback already handed to
@@ -2066,6 +2528,7 @@ window.__ModuleLoader__.load({
2066
2528
  }
2067
2529
  button.remove()
2068
2530
  companion.button = null
2531
+ companion.hideHint = null
2069
2532
  companion.trigger = null
2070
2533
  companion.observer = null
2071
2534
  teardown?.()
@@ -2078,7 +2541,7 @@ window.__ModuleLoader__.load({
2078
2541
  // `remote.session` is the namespace the create request travels through
2079
2542
  // (see `createBoundSession`); the harness's own client plugins inject it
2080
2543
  // the same way.
2081
- inject: ['workspaces', 'uiWorkspace', 'sessions', 'remote', 'remote.session'],
2544
+ inject: ['workspaces', 'uiWorkspace', 'sessions', 'remote', 'remote.session', 'shortcuts'],
2082
2545
  apply(ctx) {
2083
2546
  ctx.effect(() => install(ctx), 'wsl-desktop: sidebar WSL workspace trigger')
2084
2547
  },
package/lib/host/http.js CHANGED
@@ -9,11 +9,14 @@
9
9
  */
10
10
 
11
11
  import { randomBytes, timingSafeEqual } from 'node:crypto'
12
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
12
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
13
13
  import { homedir } from 'node:os'
14
14
  import { dirname, join } from 'node:path'
15
15
  import { MAX_BODY_BYTES, DEV_TOKEN_HEADER, tokenMatches } from '../http-admission.js'
16
16
 
17
+ /** Ceiling for the route-failure log, so a repeated failure cannot fill the disk. */
18
+ const ROUTE_LOG_MAX_BYTES = 256 * 1024
19
+
17
20
  /**
18
21
  * The harness home that holds the user preset root.
19
22
  * @returns {string} the resolved `$DSH_HOME`.
@@ -67,11 +70,62 @@ export function sendRejection(res, rejection) {
67
70
  sendJson(res, rejection.status, { ok: false, code: rejection.code, error: rejection.message })
68
71
  }
69
72
 
70
- /** Trust surface consumed here; the browser-side connection package owns the full type. */
73
+ /**
74
+ * Trust surface consumed here; the browser-side connection package owns the full type.
75
+ *
76
+ * `ctx.connection` is the only spelling that works, and it works because this
77
+ * plugin declares `connection` in its `inject` list. There is deliberately NO
78
+ * `ctx.get` fallback: Cordis resolves a service property against the CALLING
79
+ * context and THROWS for a service the caller never injected (`cannot get
80
+ * property "connection" without inject`) rather than returning `undefined`, so a
81
+ * fallback could not rescue that case — and when the service is genuinely
82
+ * unprovided, `ctx.get` returns `undefined` too, which the caller already
83
+ * handles. A fallback here would suggest a protection that does not exist.
84
+ * @param {object} ctx - the plugin context.
85
+ * @returns {object | undefined} the connection handle, when this composition has one.
86
+ */
71
87
  export function connectionOf(ctx) {
72
88
  return Reflect.get(ctx, 'connection')
73
89
  }
74
90
 
91
+ /**
92
+ * Append one route failure to a log the operator can read.
93
+ *
94
+ * The route answers the browser with a code and nothing else — deliberately, so
95
+ * a host path or a wsl.exe stderr never crosses the wire. That is right for
96
+ * production and useless when the ROUTE ITSELF throws before it can name a
97
+ * reason: the browser shows `internal`, the host logs through a channel the
98
+ * desktop does not surface, and the only remaining evidence is this file. It is
99
+ * written on the operator's own disk, never sent anywhere.
100
+ * @param {string} step - the admission step that failed.
101
+ * @param {unknown} error - the thrown value.
102
+ * @returns {Promise<void>} settlement; a logging failure never masks the refusal.
103
+ */
104
+ export async function logRouteFailure(step, error) {
105
+ try {
106
+ const path = join(dshHome(), 'wsl-desktop-route-error.log')
107
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error)
108
+ const line = `${new Date().toISOString()} [${step}] ${detail}\n`
109
+ await mkdir(dirname(path), { recursive: true })
110
+ // Bounded, because a route that fails on every request would otherwise fill
111
+ // the operator's disk. Crossing the cap restarts the file with a note, so
112
+ // the NEWEST failure is always present and the file never grows without
113
+ // limit. A silently truncated diagnostic is worse than a fresh one.
114
+ let size = 0
115
+ try {
116
+ size = (await stat(path)).size
117
+ } catch {
118
+ size = 0
119
+ }
120
+ if (size + Buffer.byteLength(line) > ROUTE_LOG_MAX_BYTES) {
121
+ await writeFile(path, `[truncated above ${ROUTE_LOG_MAX_BYTES} bytes]\n`, 'utf8')
122
+ }
123
+ await writeFile(path, line, { flag: 'a' })
124
+ } catch {
125
+ // A diagnostic that throws must not change the response the caller gets.
126
+ }
127
+ }
128
+
75
129
  /**
76
130
  * This installation's development token, minted on first use.
77
131
  *
package/lib/host/state.js CHANGED
@@ -56,3 +56,37 @@ export const bindingTape = (() => {
56
56
  },
57
57
  }
58
58
  })()
59
+
60
+ /**
61
+ * Wait briefly for a session's agent to appear in the registry.
62
+ *
63
+ * `api-session/added` fires BEFORE the harness registers the session's agent, so
64
+ * the post-hoc preset fallback raced it: `agents.get(sessionId)` was `undefined`,
65
+ * the fallback recorded "agent not registered" and returned — leaving a session
66
+ * whose cwd is a WSL path running in the WINDOWS world, permanently, because the
67
+ * harness fixes a preset at creation and refuses a later select. Measured on
68
+ * Desktop 0.2.2-rc.3 (a session in a WSL-UNC workspace, `ok:false` in the binding
69
+ * log). A bounded wait turns a guaranteed loss into a wait the registry wins.
70
+ *
71
+ * `now` and `sleep` are injectable so the wait is testable without real time.
72
+ * @param {{ get: (sessionId: unknown) => unknown }} agents - the agents registry.
73
+ * @param {unknown} sessionId - the session whose agent is wanted.
74
+ * @param {{ deadlineMs?: number, pollMs?: number, now?: () => number, sleep?: (ms: number) => Promise<void> }} [options] -
75
+ * the bound, the poll interval, and the clock/sleeper (defaults are real).
76
+ * @returns {Promise<unknown>} the agent, or `undefined` when the bound expires.
77
+ */
78
+ export async function waitForAgent(agents, sessionId, options = {}) {
79
+ const deadlineMs = options.deadlineMs ?? 2_000
80
+ const pollMs = options.pollMs ?? 50
81
+ const now = options.now ?? Date.now
82
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => { setTimeout(resolve, ms) }))
83
+ const startedAt = now()
84
+ for (;;) {
85
+ const agent = agents.get(sessionId)
86
+ if (agent !== undefined) return agent
87
+ // Checked AFTER a read so an already-registered agent never waits at all, and
88
+ // so the deadline is a bound on waiting rather than on attempts.
89
+ if (now() - startedAt >= deadlineMs) return undefined
90
+ await sleep(pollMs)
91
+ }
92
+ }
package/lib/index.js CHANGED
@@ -22,12 +22,12 @@ import {
22
22
  MAX_BODY_BYTES, DEV_TOKEN_HEADER, bodyAdmission, dispatchAdmission, fenceRejection, methodAdmission, preflight, tokenMatches,
23
23
  } from './http-admission.js'
24
24
  import {
25
- connectionOf, devToken, isDeveloperCaller, readJsonBody, sendJson, sendRejection,
25
+ connectionOf, devToken, isDeveloperCaller, logRouteFailure, readJsonBody, sendJson, sendRejection,
26
26
  } from './host/http.js'
27
27
  import {
28
28
  GENERATED_PREFIX, basePluginsOf, runShellSpec, sourceDirFor, variantIdFor,
29
29
  } from './host/presets.js'
30
- import { bindingTape, hostSession, presetRun } from './host/state.js'
30
+ import { bindingTape, hostSession, presetRun, waitForAgent } from './host/state.js'
31
31
  import { buildVariantPlugins } from './wsl/preset.js'
32
32
  import { setLocalSubprocess } from './wsl/host-refs.js'
33
33
  import { checkLinuxPath, defaultWorkspaceUnc, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
@@ -49,8 +49,17 @@ export const name = 'wsl-desktop'
49
49
  * carries the composition's trust fence: its Host/Origin check defeats DNS
50
50
  * rebinding, and its browser authentication (the login-token cookie) gates every
51
51
  * caller before any method runs.
52
+ *
53
+ * `connection.authentication` is a SERVICE IN ITS OWN RIGHT (`super(ctx,
54
+ * 'connection.authentication')`), and `connection.admit` reads it internally.
55
+ * Cordis resolves that read against the CALLING context, so a plugin that calls
56
+ * `admit` must inject it too — otherwise the call dies with `cannot get property
57
+ * "connection.authentication" without inject`, thrown from inside `admit`
58
+ * itself. That is exactly how the whole W dialog broke on Desktop 0.2.2-rc.3:
59
+ * the route was reachable, admission threw, and the host's last-resort listener
60
+ * answered an empty-bodied 400 that the browser could only call `internal`.
52
61
  */
53
- export const inject = ['webServer', 'connection']
62
+ export const inject = ['webServer', 'connection', 'connection.authentication']
54
63
 
55
64
  /** Plugin config. */
56
65
  export const Config = z.object({
@@ -1484,6 +1493,17 @@ export function apply(ctx, config) {
1484
1493
  ctx.effect(() => ctx.webServer.register({
1485
1494
  kind: 'exact',
1486
1495
  path: config.routePath,
1496
+ // Admission is this handler's job, so the route must not also be gated by
1497
+ // the webserver. Harness 0.2.2-rc.3 (commit 71753fdf91, "centralize route
1498
+ // admission with required policy") added this field and defaults an omitted
1499
+ // one to `required` — which refuses the request BEFORE this handler runs.
1500
+ // The observable result was a silent total failure: the W dialog rendered,
1501
+ // every method answered an empty-bodied 401, and the browser half could only
1502
+ // report it as `internal`. Declaring `anonymous` hands admission back to the
1503
+ // fence below, which applies the same two arms and additionally lets a
1504
+ // development token replace the 401 (a script cannot present a browser
1505
+ // cookie). The host still applies its Host/Origin check to anonymous routes.
1506
+ auth: 'anonymous',
1487
1507
  handler: async (req, res) => {
1488
1508
  // The fence first, exactly as every shipped host route does it: without
1489
1509
  // this, a cross-site `text/plain` POST is a CORS-simple request with no
@@ -1493,14 +1513,66 @@ export function apply(ctx, config) {
1493
1513
  // replaces the SECOND arm only — a local script cannot present a browser
1494
1514
  // cookie — and never the first: a token holder gains nothing from a
1495
1515
  // trusted Host, but a foreign Host must stay refused for every caller.
1496
- const developer = await isDeveloperCaller(req, config)
1497
- const rejection = fenceRejection({
1498
- rejection: connectionOf(ctx).requestRejection(req),
1499
- developer,
1500
- })
1501
- if (rejection !== undefined) {
1502
- res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
1503
- res.end()
1516
+ //
1517
+ // `admit` is the accessor to use: `requestRejection` was the same two arms
1518
+ // and was REMOVED in the same 0.2.2-rc.3 commit. `admit` predates it and
1519
+ // survives it, so this call site works on both generations.
1520
+ // EVERYTHING from here to the response is inside one guard, because the
1521
+ // host's last-resort listener turns an escaping throw into an EMPTY-bodied
1522
+ // 400 (`webserver/src/index.ts`: `dispatch().catch(() => res.writeHead(400)`).
1523
+ // That answer is indistinguishable, at the browser, from a transport
1524
+ // refusal: the client's `json()` throws and the dialog shows only
1525
+ // `internal`. Measured on Desktop 0.2.2-rc.3, where admission threw and the
1526
+ // whole W dialog became an opaque "plugin internal error" — the single
1527
+ // hardest failure to diagnose this route has produced. A JSON answer keeps
1528
+ // the code, the ref and (for a developer caller) the message.
1529
+ let developer = false
1530
+ // The step is tracked so the refusal can NAME where admission broke. A
1531
+ // browser cannot present the development token (it is a header a page
1532
+ // never sends), so `developer` is false for every real page and the
1533
+ // response can carry nothing about the cause — the file log is the only
1534
+ // channel left. `step` itself is a fixed vocabulary word, not host data,
1535
+ // so it is safe to return.
1536
+ let step = 'developer'
1537
+ try {
1538
+ developer = await isDeveloperCaller(req, config)
1539
+ step = 'connection'
1540
+ const connection = connectionOf(ctx)
1541
+ if (connection === undefined || typeof connection.admit !== 'function') {
1542
+ throw new Error(`the composition exposes no connection admission (got ${connection === undefined ? 'undefined' : typeof connection})`)
1543
+ }
1544
+ step = 'admit'
1545
+ const admission = connection.admit(req)
1546
+ step = 'fence'
1547
+ const rejection = fenceRejection({
1548
+ rejection: 'rejection' in admission ? admission.rejection : undefined,
1549
+ developer,
1550
+ })
1551
+ if (rejection !== undefined) {
1552
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
1553
+ res.end()
1554
+ return
1555
+ }
1556
+ } catch (error) {
1557
+ // The stack goes to the operator's disk; the browser gets the shared
1558
+ // classifier's envelope. The `step` is carried INSIDE a WslRouteError so
1559
+ // it passes through `browserFailure`'s SAFE_PARAMS filter like every
1560
+ // other param — appending it afterwards would make the allowlist entry
1561
+ // decorative and the filter a lie. The constructor also supplies the
1562
+ // correlation `ref` that `internal` always carries. The message itself
1563
+ // is never sent — that is where host paths live — which is why the file
1564
+ // log above exists.
1565
+ //
1566
+ // `internal` is stated rather than inferred, because the four steps above
1567
+ // are admission plumbing: they raise TypeErrors and plain Errors, never a
1568
+ // WslDomainError whose code would mean something to the browser. Passing
1569
+ // the original as `cause` keeps it attached for anything that inspects the
1570
+ // error object, without letting it change the wire code.
1571
+ void logRouteFailure(step, error)
1572
+ sendJson(res, 500, {
1573
+ ok: false,
1574
+ ...browserFailure(new WslRouteError('internal', { step }, { cause: error }), { discovery: false }),
1575
+ })
1504
1576
  return
1505
1577
  }
1506
1578
  const early = preflight({ httpMethod: req.method, contentType: req.headers['content-type'] })
@@ -1643,9 +1715,15 @@ async function bindWslSession(ctx, summary) {
1643
1715
  if (parseWslUnc(cwd) === null) return
1644
1716
  const agents = ctx.get('agents')
1645
1717
  if (agents === undefined) return
1646
- const agent = agents.get(summary?.sessionId)
1718
+ // WAIT, do not give up. `api-session/added` fires before the agent is
1719
+ // registered, so the immediate `agents.get` below used to lose that race EVERY
1720
+ // time: the fallback recorded "agent not registered" and returned, and the
1721
+ // session stayed in the Windows world for good (the harness fixes a preset at
1722
+ // creation). A bounded wait is the difference between a guaranteed loss and a
1723
+ // wait the registry wins.
1724
+ const agent = await waitForAgent(agents, summary?.sessionId)
1647
1725
  if (agent === undefined) {
1648
- recordBinding({ sessionId: String(summary?.sessionId), cwd, ok: false, error: 'agent 尚未注册' })
1726
+ recordBinding({ sessionId: String(summary?.sessionId), cwd, ok: false, error: 'agent 尚未注册(已等待注册上限)' })
1649
1727
  return
1650
1728
  }
1651
1729
  await bindWslPreset(ctx, agent, cwd, String(summary?.sessionId))
package/lib/protocol.js CHANGED
@@ -197,8 +197,8 @@ export const CHECK_REMEDIES = deepFreeze({
197
197
  },
198
198
  runner: {
199
199
  kind: 'text',
200
- zh: '受限模式需要免密 sudo + unshare/setpriv,或安装 dsh-wsl-confine helper;danger-full-access 不受影响。',
201
- en: 'Restricted mode needs passwordless sudo plus unshare/setpriv, or the dsh-wsl-confine helper; danger-full-access is unaffected.',
200
+ zh: '受限模式需要免密 sudo + unshare/setpriv,或安装 dsh-wsl-confine helper。两条出路:给会话用户配 NOPASSWD sudo,或在权限菜单里选「完全权限」(后者不需要任何 sudo,也不再受文件围栏约束)。',
201
+ en: 'Restricted mode needs passwordless sudo plus unshare/setpriv, or the dsh-wsl-confine helper. Two ways out: give the session user NOPASSWD sudo, or pick full access in the permission menu — that needs no sudo at all, and is no longer governed by the file fence.',
202
202
  },
203
203
  helper: {
204
204
  kind: 'text',
@@ -236,7 +236,12 @@ export const SAFE_PARAMS = deepFreeze({
236
236
  'host-capability-missing': ['feature'],
237
237
  'host-plugin-stale': ['method'],
238
238
  'too-busy': [],
239
- internal: ['ref'],
239
+ // `step` names WHICH admission step refused the request (a fixed vocabulary:
240
+ // developer / connection / admit / fence). It exists because the route can
241
+ // throw before it has any other way to say where — and a browser caller can
242
+ // never be told the message, since that is where host paths live. It is a
243
+ // vocabulary word, not host data, which is what makes it safe to send.
244
+ internal: ['ref', 'step'],
240
245
  transport: [],
241
246
  'catalog-unavailable': [],
242
247
  })
@@ -67,3 +67,80 @@ export function installWindow({ publishedAt, minimumReleaseAgeMinutes, now = new
67
67
  : `inside the minimumReleaseAge window until ${readyAt.toISOString()}`,
68
68
  }
69
69
  }
70
+
71
+ /** The receipt schema this check knows how to read. */
72
+ const RECEIPT_VERSION = 1
73
+
74
+ /**
75
+ * The one suite that reaches the route handler, and therefore the only evidence
76
+ * that the seam works on the installed plugin. Its admitted `bootstrap` check is
77
+ * what failed to run before 2026-11-14 while the offline gate stayed green.
78
+ */
79
+ const COVERING_SUITE = 'verify-route.mjs'
80
+
81
+ /**
82
+ * Decide whether a recorded gate run may vouch for this release.
83
+ *
84
+ * The route-admission seam is covered ONLY by the live suites: an offline run
85
+ * cannot execute the handler at all, because reaching it needs a browser cookie
86
+ * or the development token. On 2026-11-14 a host change broke every route call
87
+ * while the offline gate stayed green at 25/27 — `verify-route.mjs` already
88
+ * asserted the admitted `bootstrap`, it simply never ran, because it sits in the
89
+ * LIVE list. The test was there; the release path never demanded it.
90
+ *
91
+ * So the release path now demands the receipt of a run that INCLUDED the live
92
+ * suites, describes the version being released, and failed nothing. The version
93
+ * comparison is what stops a receipt from an older build vouching for this one.
94
+ *
95
+ * `receipt.version` is read from the REPOSITORY manifest, while the live suites
96
+ * exercise the INSTALLED plugin — so the two could describe different code. They
97
+ * cannot here, and the reason is transitive: `verify-live-env.mjs` compares the
98
+ * running host's reported `pluginVersion` against that same manifest and turns a
99
+ * mismatch into a FAILED check, which lands in `counts.failed` and trips the
100
+ * rule above. A stale install therefore cannot produce a usable receipt; it
101
+ * produces a red one.
102
+ * @param {{ receipt: object|null, target: string }} input - the parsed receipt, or null when absent.
103
+ * @returns {{ ready: boolean, blockers: string[] }} the verdict.
104
+ */
105
+ export function liveGateEvidence({ receipt, target }) {
106
+ if (receipt === null || typeof receipt !== 'object') {
107
+ return {
108
+ ready: false,
109
+ blockers: ['no gate receipt: run `node scripts/verify-all.mjs --live --receipt` before releasing'],
110
+ }
111
+ }
112
+ const blockers = []
113
+ // The schema version is checked FIRST because every rule below reads fields this
114
+ // function assumes it understands. A future receipt that redefines `counts` or
115
+ // `liveRan` would otherwise be interpreted as if it still meant the old thing.
116
+ if (receipt.receiptVersion !== RECEIPT_VERSION) {
117
+ blockers.push(`the receipt is schema version ${String(receipt.receiptVersion)}, and this check understands ${RECEIPT_VERSION}`)
118
+ return { ready: false, blockers }
119
+ }
120
+ if (receipt.liveRan !== true) {
121
+ blockers.push('the recorded gate run did NOT include the live suites (--live), and those are the only cover for the route-admission seam')
122
+ }
123
+ const failed = receipt?.counts?.failed
124
+ if (typeof failed !== 'number' || failed > 0) {
125
+ blockers.push(`the recorded gate run reported ${typeof failed === 'number' ? failed : 'an unreadable number of'} failure(s)`)
126
+ }
127
+ if (receipt.version !== target) {
128
+ blockers.push(`the receipt describes version ${String(receipt.version)}, not the target ${target} — re-run the gate after the version bump`)
129
+ }
130
+ // `liveRan` records the FLAG, not that anything ran. Without this the gate would
131
+ // accept a receipt whose live suites all skipped — a run that never reached the
132
+ // host — which is the "a green summary hides what did not run" failure this
133
+ // whole artefact exists to prevent, reintroduced one layer up.
134
+ const rows = Array.isArray(receipt.rows) ? receipt.rows : null
135
+ if (rows === null) {
136
+ blockers.push('the receipt carries no per-suite rows, so it cannot show that the suite covering the route-admission seam passed')
137
+ } else {
138
+ const covering = rows.find((row) => row?.suite === COVERING_SUITE)
139
+ if (covering === undefined) {
140
+ blockers.push(`the receipt has no row for ${COVERING_SUITE}, the only suite that reaches the route handler`)
141
+ } else if (covering.outcome !== 'passed') {
142
+ blockers.push(`${COVERING_SUITE} is recorded as ${String(covering.outcome)}${covering.reason === undefined || covering.reason === '' ? '' : ` (${covering.reason})`}, and it is the only suite that reaches the route handler`)
143
+ }
144
+ }
145
+ return { ready: blockers.length === 0, blockers }
146
+ }
package/lib/wsl/preset.js CHANGED
@@ -97,6 +97,14 @@ export const VERDICTED_KEPT_MODULES = new Set([
97
97
  '@deepseek-ai/dsh-tool-web',
98
98
  '@deepseek-ai/dsh-tool-cordis',
99
99
  '@deepseek-ai/dsh-tool-present',
100
+ // Added when harness 0.2.2-rc.3 shipped it (measured 2026-11-15: a `wsl-standard`
101
+ // and a `wsl-ptc` row survived unclassified). KEPT, and the reason is its inject
102
+ // list: `['tools', 'fs', 'workingDirectory', 'agents']` — it reaches files through
103
+ // the `fs` SERVICE, which a WSL preset mounts from this plugin, and its source
104
+ // carries no `node:fs`, no `spawn` and no `execFile`. So it neither runs a host
105
+ // command nor touches host files behind the model's back: it is world-agnostic,
106
+ // unlike `dsh-tool-executor-ptc`, which injects a host runtime and is removed.
107
+ '@deepseek-ai/dsh-tool-office',
100
108
  ])
101
109
 
102
110
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.7.6",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",
@@ -13,8 +13,19 @@
13
13
  "prepack": "node -e \"const fs=require('node:fs'),p=require('node:path');(function w(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){if(e.isDirectory()){const f=p.join(d,e.name);if(e.name==='__pycache__'){fs.rmSync(f,{recursive:true,force:true})}else{w(f)}}}})('lib')\""
14
14
  },
15
15
  "engines": {
16
- "node": ">=22.19"
16
+ "node": ">=22.19",
17
+ "dsh": ">=0.1.7-0"
17
18
  },
19
+ "keywords": [
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "deepseek-harness",
23
+ "wsl",
24
+ "wsl2",
25
+ "workspace",
26
+ "execution-world",
27
+ "windows"
28
+ ],
18
29
  "files": [
19
30
  "lib",
20
31
  "cordis.patch.yml",