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 +21 -3
- package/README.md +16 -3
- package/lib/client.js +470 -7
- package/lib/host/http.js +56 -2
- package/lib/host/state.js +34 -0
- package/lib/index.js +91 -13
- package/lib/protocol.js +8 -3
- package/lib/release-check.js +77 -0
- package/lib/wsl/preset.js +8 -0
- package/package.json +13 -2
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,
|
|
131
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
|
|
340
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
en: 'Restricted mode needs passwordless sudo plus unshare/setpriv, or the dsh-wsl-confine helper
|
|
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
|
-
|
|
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
|
})
|
package/lib/release-check.js
CHANGED
|
@@ -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.
|
|
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",
|