dsh-wsl-desktop 0.7.1 → 0.7.3
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 +1 -0
- package/README.md +1 -0
- package/lib/client.js +177 -7
- package/lib/host/http.js +110 -0
- package/lib/host/presets.js +97 -0
- package/lib/host/state.js +58 -0
- package/lib/index.js +100 -276
- package/lib/wsl/distros.js +190 -0
- package/lib/wsl/paths.js +12 -1
- package/lib/wsl/preset.js +7 -0
- package/lib/wsl/proc.js +182 -0
- package/lib/wsl/pty.js +89 -9
- package/lib/wsl/shell.js +2 -1
- package/lib/wsl/subprocess.js +4 -3
- package/lib/wsl/world.js +4 -341
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -157,6 +157,7 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
|
|
|
157
157
|
- [Distro support matrix](docs/DISTRO-SUPPORT.md) — which distros were verified, and what each one needs
|
|
158
158
|
- [Compatibility and upgrades](docs/COMPATIBILITY.md) — plugin/desktop version relation, upgrade order, rollback steps
|
|
159
159
|
- [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the two proposals to the harness upstream
|
|
160
|
+
- [Upstream defect reports](issues/README.md) — the harness issues filed, their measured evidence, and how the local workarounds relate
|
|
160
161
|
- [Architecture and flow diagrams](docs/diagrams.md) — interactive architecture, sequence and lifecycle diagrams
|
|
161
162
|
|
|
162
163
|
Three of the linked documents — the distro support matrix, the upstream proposals and the diagram
|
package/README.md
CHANGED
|
@@ -130,6 +130,7 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
130
130
|
- [发行版支持矩阵](docs/DISTRO-SUPPORT.md) — 哪些发行版验证过,每种缺什么
|
|
131
131
|
- [兼容性与升级](docs/COMPATIBILITY.md) — 插件与桌面的版本关系、升级顺序、回退步骤
|
|
132
132
|
- [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的两项提案
|
|
133
|
+
- [上游缺陷记录](issues/README.md) — 已上报 harness 的问题、实测证据与本地规避的关系
|
|
133
134
|
- [架构与流程图](docs/diagrams.md) — 可交互的架构、时序与生命周期图
|
|
134
135
|
|
|
135
136
|
其中三份——发行版支持矩阵、上游 API 提案、架构与流程图索引——**只有中文版**,因为目前没有对应的
|
package/lib/client.js
CHANGED
|
@@ -21,8 +21,23 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
id: 'dsh-wsl-desktop',
|
|
22
22
|
factory() {
|
|
23
23
|
// MUST stay equal to the host half's Config.routePath default (lib/index.js).
|
|
24
|
+
/*
|
|
25
|
+
* Section index. This file is ONE loader module: `window.__ModuleLoader__`
|
|
26
|
+
* evaluates a single factory, and splitting it across files would need a
|
|
27
|
+
* loader capability that has not been measured. The boundaries below are
|
|
28
|
+
* therefore comments, not files — they exist so a reader can hold one
|
|
29
|
+
* section at a time, and so a future split has its seams already named.
|
|
30
|
+
* 1. endpoint, trigger geometry and locale
|
|
31
|
+
* 2. copy, theme and shared node helpers
|
|
32
|
+
* 3. route calls and failure rendering
|
|
33
|
+
* 4. the W workspace picker dialog
|
|
34
|
+
* 5. the companion button and workspace rows
|
|
35
|
+
* 6. module registration and dispose
|
|
36
|
+
*/
|
|
37
|
+
|
|
24
38
|
// Nothing derives one from the other: a deployment that mounts the route
|
|
25
39
|
// elsewhere loses the picker silently.
|
|
40
|
+
// ── 1. endpoint, trigger geometry and locale ─────────────────────────────
|
|
26
41
|
const ENDPOINT = '/wsl-desktop/api'
|
|
27
42
|
|
|
28
43
|
/**
|
|
@@ -38,6 +53,34 @@ window.__ModuleLoader__.load({
|
|
|
38
53
|
'M5.54492 2.06738', // post-refactor ProjectAddOutlineArtwork
|
|
39
54
|
]
|
|
40
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The same trigger, named by sprite symbol instead of path data.
|
|
58
|
+
*
|
|
59
|
+
* Desktop 0.2.1-rc3 (ui-primitives `c3eead771d`) moved every product icon
|
|
60
|
+
* into the document icon sprite: the header button now renders
|
|
61
|
+
* `<svg data-icon="dsw-icon-project-add-outline-regular"><use href="#…"/></svg>`
|
|
62
|
+
* with NO `<path>` child anywhere. The geometry scan above therefore matched
|
|
63
|
+
* nothing, the anchor read as absent, and the W button stood down on every
|
|
64
|
+
* 0.2.1-rc3 desktop. The glyph name is the locale-independent identity in
|
|
65
|
+
* that generation, exactly as the path prefix was in the previous one, and a
|
|
66
|
+
* theme may replace the artwork without renaming the symbol — so the name
|
|
67
|
+
* survives the change that broke the geometry.
|
|
68
|
+
*/
|
|
69
|
+
const TRIGGER_ICON_GLYPHS = ['project-add-outline']
|
|
70
|
+
|
|
71
|
+
/** Sprite symbol id shape, as `iconSymbolId` composes it: `dsw-icon-<glyph>-<weight>`. */
|
|
72
|
+
const SPRITE_ICON_ID = /^#?dsw-icon-(.+)-(?:regular|medium)$/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The row glyphs whose artwork is FILL-only, as sprite names.
|
|
76
|
+
*
|
|
77
|
+
* Only the fallback for a sprite symbol that is not in the document yet; the
|
|
78
|
+
* live read (`isSolidIcon`) prefers the symbol's own geometry. Versions
|
|
79
|
+
* qualify the id (`symbolId(glyph, weight)`), so the suffix is not part of a
|
|
80
|
+
* glyph name and the match is on the glyph alone.
|
|
81
|
+
*/
|
|
82
|
+
const SOLID_GLYPHS = ['folder-open']
|
|
83
|
+
|
|
41
84
|
/** Namespace of every SVG node this half creates. */
|
|
42
85
|
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
43
86
|
|
|
@@ -74,6 +117,7 @@ window.__ModuleLoader__.load({
|
|
|
74
117
|
* The catalog is bilingual, so the dialog can render a host failure in the
|
|
75
118
|
* operator's language without a translation build step.
|
|
76
119
|
*/
|
|
120
|
+
// ── 2. copy, theme and shared node helpers ─────────────────────────────
|
|
77
121
|
const LOCALE = typeof navigator !== 'undefined' && /^zh/i.test(String(navigator.language ?? '')) ? 'zh' : 'en'
|
|
78
122
|
|
|
79
123
|
/**
|
|
@@ -288,6 +332,7 @@ window.__ModuleLoader__.load({
|
|
|
288
332
|
* @param {object|null} envelope - the route's JSON body, when it parsed.
|
|
289
333
|
* @returns {Error} the failure, with `code` and `params` attached.
|
|
290
334
|
*/
|
|
335
|
+
// ── 3. route calls and failure rendering ─────────────────────────────
|
|
291
336
|
function routeFailure(method, status, envelope) {
|
|
292
337
|
const error = new Error(envelope?.error ?? `${method} failed (HTTP ${status})`)
|
|
293
338
|
error.code = typeof envelope?.code === 'string' ? envelope.code : status === 0 ? 'transport' : 'internal'
|
|
@@ -705,6 +750,7 @@ window.__ModuleLoader__.load({
|
|
|
705
750
|
* so lastIndexOf returned -1 and slice(0, -1) gave 'hom'/'tm' — the host
|
|
706
751
|
* listed /tmp while the "up" button navigated to /tm.
|
|
707
752
|
*/
|
|
753
|
+
// ── 4. the W workspace picker dialog ─────────────────────────────
|
|
708
754
|
function parentOf(path) {
|
|
709
755
|
const absolute = path.startsWith('/') ? path : `/${path}`
|
|
710
756
|
const trimmed = absolute.replace(/\/+$/, '')
|
|
@@ -1471,20 +1517,74 @@ window.__ModuleLoader__.load({
|
|
|
1471
1517
|
}
|
|
1472
1518
|
|
|
1473
1519
|
/** The injected services the picker and its trigger read. */
|
|
1520
|
+
// ── 5. the companion button and workspace rows ─────────────────────────────
|
|
1474
1521
|
const companion = { button: null, fallback: null, trigger: null, observer: null, scheduled: false, disposed: false }
|
|
1475
1522
|
|
|
1476
1523
|
/** Whether the missing-workspace-source warning was already printed. */
|
|
1477
1524
|
let warnedWorkspaceSource = false
|
|
1478
1525
|
|
|
1479
|
-
/**
|
|
1480
|
-
|
|
1526
|
+
/**
|
|
1527
|
+
* The sprite glyph one `<use>` reference names, or null.
|
|
1528
|
+
*
|
|
1529
|
+
* Accepts both the bare symbol id and the `#`-prefixed href form, and both
|
|
1530
|
+
* `href` and the legacy `xlink:href` spelling, because an icon may render
|
|
1531
|
+
* either. Returning the GLYPH (not the whole id) lets the trigger list stay
|
|
1532
|
+
* a list of glyph names, which is what a desktop generation actually fixes.
|
|
1533
|
+
* @param {Element} node - a `<use>` element.
|
|
1534
|
+
* @returns {string | null} the glyph name, or null when it is not a sprite ref.
|
|
1535
|
+
*/
|
|
1536
|
+
function spriteGlyphOf(node) {
|
|
1537
|
+
const ref = node.getAttribute('href')
|
|
1538
|
+
?? node.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
|
|
1539
|
+
if (ref === null) return null
|
|
1540
|
+
return SPRITE_ICON_ID.exec(ref)?.[1] ?? null
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* The candidate trigger buttons carrying one generation's icon identity.
|
|
1545
|
+
*
|
|
1546
|
+
* Each generation is ONE document-level selector rather than a walk over
|
|
1547
|
+
* every button: `findTrigger` re-runs on every animation frame whenever the
|
|
1548
|
+
* anchor is missing, so a per-button subtree query would turn a single scan
|
|
1549
|
+
* into N of them exactly when the header is already in trouble.
|
|
1550
|
+
* @param {string} selector - the icon nodes this generation renders.
|
|
1551
|
+
* @param {(node: Element) => boolean} identifies - whether one icon is the trigger's.
|
|
1552
|
+
* @returns {HTMLElement[]} the distinct buttons owning a matching icon.
|
|
1553
|
+
*/
|
|
1554
|
+
function triggerCandidates(selector, identifies) {
|
|
1481
1555
|
const matches = []
|
|
1482
|
-
for (const
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
const trigger = path.closest('button')
|
|
1556
|
+
for (const icon of document.querySelectorAll(selector)) {
|
|
1557
|
+
if (!identifies(icon)) continue
|
|
1558
|
+
const trigger = icon.closest('button')
|
|
1486
1559
|
if (trigger !== null && !matches.includes(trigger)) matches.push(trigger)
|
|
1487
1560
|
}
|
|
1561
|
+
return matches
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** Whether one inline path carries the add-workspace geometry. */
|
|
1565
|
+
function isTriggerPath(path) {
|
|
1566
|
+
const d = path.getAttribute('d')
|
|
1567
|
+
return TRIGGER_ICON_PATHS.some((geometry) => d.startsWith(geometry))
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Whether one icon node is the add-workspace artwork, in either generation.
|
|
1572
|
+
*
|
|
1573
|
+
* A sprite icon is identified by `data-icon` (the id the component was
|
|
1574
|
+
* CONSTRUCTED with, which a theme replacement does not change) and, failing
|
|
1575
|
+
* that, by the `<use>` reference it renders.
|
|
1576
|
+
* @param {Element} icon - an inline path or a sprite `<svg>`/`<use>`.
|
|
1577
|
+
* @returns {boolean} true when it is the trigger's artwork.
|
|
1578
|
+
*/
|
|
1579
|
+
function isTriggerIcon(icon) {
|
|
1580
|
+
if (icon.tagName === 'path') return isTriggerPath(icon)
|
|
1581
|
+
const declared = icon.tagName === 'svg' ? icon.getAttribute('data-icon') : null
|
|
1582
|
+
const glyph = declared !== null ? SPRITE_ICON_ID.exec(declared)?.[1] : spriteGlyphOf(icon)
|
|
1583
|
+
return glyph !== undefined && glyph !== null && TRIGGER_ICON_GLYPHS.includes(glyph)
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Pick the trigger out of one generation's candidates, or null. */
|
|
1587
|
+
function pickTrigger(matches) {
|
|
1488
1588
|
if (matches.length <= 1) return matches[0] ?? null
|
|
1489
1589
|
// More than one control carries the geometry. The shipped trigger is the
|
|
1490
1590
|
// one inside a `*_headerActions` cluster — the CSS-module local name is
|
|
@@ -1494,6 +1594,24 @@ window.__ModuleLoader__.load({
|
|
|
1494
1594
|
return matches.find((trigger) => hasHeaderActionsParent(trigger)) ?? null
|
|
1495
1595
|
}
|
|
1496
1596
|
|
|
1597
|
+
/**
|
|
1598
|
+
* Find the shipped "add workspace" trigger in either desktop generation.
|
|
1599
|
+
*
|
|
1600
|
+
* The sprite generation is scanned FIRST because it is the newer one: on a
|
|
1601
|
+
* desktop that renders it, that single scan is the whole cost. Only a
|
|
1602
|
+
* desktop with no sprite icon anywhere falls through to the geometry scan,
|
|
1603
|
+
* which is what every release before 0.2.1-rc3 needed.
|
|
1604
|
+
* @returns {HTMLElement | null} the anchor, or null when this desktop has none.
|
|
1605
|
+
*/
|
|
1606
|
+
function findTrigger() {
|
|
1607
|
+
const sprite = triggerCandidates('svg[data-icon], use', isTriggerIcon)
|
|
1608
|
+
// A non-empty sprite result is this desktop's answer even when it is
|
|
1609
|
+
// ambiguous: `pickTrigger` returns null there to mean "stand down", and
|
|
1610
|
+
// falling through would let an unrelated inline icon pick the anchor.
|
|
1611
|
+
if (sprite.length > 0) return pickTrigger(sprite)
|
|
1612
|
+
return pickTrigger(triggerCandidates('svg > path[d]', isTriggerIcon))
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1497
1615
|
/**
|
|
1498
1616
|
* Whether one trigger sits in the shipped trailing action cluster.
|
|
1499
1617
|
* @param {HTMLElement} trigger - a candidate trigger button.
|
|
@@ -1637,7 +1755,7 @@ window.__ModuleLoader__.load({
|
|
|
1637
1755
|
// artwork fills its paths, the collapsed one strokes them. A rebuild is
|
|
1638
1756
|
// owed whenever that flips, which is exactly what expanding a workspace
|
|
1639
1757
|
// does to this span.
|
|
1640
|
-
const solid = shipped
|
|
1758
|
+
const solid = isSolidIcon(shipped)
|
|
1641
1759
|
const weight = solid ? 'solid' : 'outline'
|
|
1642
1760
|
if (mine !== null && mine.dataset.wslCloudWeight !== weight) {
|
|
1643
1761
|
mine.remove()
|
|
@@ -1648,6 +1766,56 @@ window.__ModuleLoader__.load({
|
|
|
1648
1766
|
}
|
|
1649
1767
|
}
|
|
1650
1768
|
|
|
1769
|
+
/**
|
|
1770
|
+
* The sprite symbol an icon element references, as a bare symbol id.
|
|
1771
|
+
*
|
|
1772
|
+
* `data-icon` is the id the icon COMPONENT was built with, so it keeps the
|
|
1773
|
+
* shipped symbol even when a theme points the `<use>` href at its own
|
|
1774
|
+
* artwork; the href is the fallback for markup that omits `data-icon`.
|
|
1775
|
+
* @param {Element} icon - one icon `<svg>`.
|
|
1776
|
+
* @returns {string | null} the symbol id without `#`, or null.
|
|
1777
|
+
*/
|
|
1778
|
+
function spriteSymbolOf(icon) {
|
|
1779
|
+
const declared = icon.getAttribute('data-icon')
|
|
1780
|
+
if (typeof declared === 'string' && declared !== '') return declared.replace(/^#/, '')
|
|
1781
|
+
for (const use of icon.querySelectorAll('use')) {
|
|
1782
|
+
const ref = use.getAttribute('href')
|
|
1783
|
+
?? use.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
|
|
1784
|
+
if (typeof ref === 'string' && ref !== '') return ref.replace(/^#/, '')
|
|
1785
|
+
}
|
|
1786
|
+
return null
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Whether one workspace row icon renders the FILL-only artwork.
|
|
1791
|
+
*
|
|
1792
|
+
* The collapsed row strokes its folder outline and the expanded one fills
|
|
1793
|
+
* it, and the cloud has to match whichever is showing. Up to 0.2.1-rc2 the
|
|
1794
|
+
* artwork was inline, so a stroked `<path>` was the tell. From 0.2.1-rc3 the
|
|
1795
|
+
* icon is a sprite reference with NO path child anywhere: the weight then
|
|
1796
|
+
* belongs to the SYMBOL in the document sprite, not to the `<svg>` naming
|
|
1797
|
+
* it. Reading only the inline shape (as this did) made every sprite row look
|
|
1798
|
+
* fill-only, so a collapsed row got the solid cloud.
|
|
1799
|
+
* @param {Element | null} icon - the shipped folder icon, if the row renders one.
|
|
1800
|
+
* @returns {boolean} true when the icon is the fill-only artwork.
|
|
1801
|
+
*/
|
|
1802
|
+
function isSolidIcon(icon) {
|
|
1803
|
+
if (icon === null) return false
|
|
1804
|
+
// Inline generation: the artwork declares its own weight.
|
|
1805
|
+
if (icon.querySelector('path[d]') !== null) return icon.querySelector('path[stroke]') === null
|
|
1806
|
+
const symbolId = spriteSymbolOf(icon)
|
|
1807
|
+
if (symbolId === null) return false
|
|
1808
|
+
const symbol = document.getElementById(symbolId)
|
|
1809
|
+
// Sprite generation, symbol present: judge the artwork the browser paints,
|
|
1810
|
+
// so a theme's own geometry is measured rather than assumed.
|
|
1811
|
+
if (symbol !== null) return symbol.querySelector('path[stroke]') === null
|
|
1812
|
+
// Sprite generation, symbol not in the document (a theme may mount its
|
|
1813
|
+
// own sprite later): fall back to the glyph name, which is the fill-only
|
|
1814
|
+
// identity in the shipped table.
|
|
1815
|
+
const glyph = SPRITE_ICON_ID.exec(`#${symbolId}`)?.[1]
|
|
1816
|
+
return glyph !== undefined && glyph !== null && SOLID_GLYPHS.includes(glyph)
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1651
1819
|
/**
|
|
1652
1820
|
* Whether one observed mutation could have changed a workspace row's markup.
|
|
1653
1821
|
*
|
|
@@ -1904,6 +2072,8 @@ window.__ModuleLoader__.load({
|
|
|
1904
2072
|
}
|
|
1905
2073
|
}
|
|
1906
2074
|
|
|
2075
|
+
|
|
2076
|
+
// ── 6. module registration and dispose ─────────────────────────────
|
|
1907
2077
|
return {
|
|
1908
2078
|
// `remote.session` is the namespace the create request travels through
|
|
1909
2079
|
// (see `createBoundSession`); the harness's own client plugins inject it
|
package/lib/host/http.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route's HTTP seam: reading a JSON body, answering with one, and deciding
|
|
3
|
+
* whether a caller may reach the development-token surface.
|
|
4
|
+
*
|
|
5
|
+
* Extracted verbatim from `lib/index.js` (C3). Nothing in here is new behaviour;
|
|
6
|
+
* the move exists so the plugin entry file carries wiring and lifecycle rather
|
|
7
|
+
* than request plumbing.
|
|
8
|
+
* @module dsh-wsl-desktop/host/http
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
12
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { MAX_BODY_BYTES, DEV_TOKEN_HEADER, tokenMatches } from '../http-admission.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The harness home that holds the user preset root.
|
|
19
|
+
* @returns {string} the resolved `$DSH_HOME`.
|
|
20
|
+
*/
|
|
21
|
+
export function dshHome() {
|
|
22
|
+
return process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read one JSON request body, bounded.
|
|
27
|
+
* @param {import('node:http').IncomingMessage} req - the request to drain.
|
|
28
|
+
* @returns {Promise<{ text: string | null, byteLength: number }>} the body, or null past the ceiling.
|
|
29
|
+
*/
|
|
30
|
+
export async function readJsonBody(req) {
|
|
31
|
+
const chunks = []
|
|
32
|
+
let size = 0
|
|
33
|
+
for await (const chunk of req) {
|
|
34
|
+
size += chunk.byteLength
|
|
35
|
+
if (size > MAX_BODY_BYTES) {
|
|
36
|
+
// Drain the remainder so the refusal is a readable response, not a socket cut.
|
|
37
|
+
req.resume()
|
|
38
|
+
return { text: null, byteLength: size }
|
|
39
|
+
}
|
|
40
|
+
chunks.push(chunk)
|
|
41
|
+
}
|
|
42
|
+
return { text: Buffer.concat(chunks, size).toString('utf8'), byteLength: size }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Answer one request with a JSON envelope and an explicit status.
|
|
47
|
+
* @param {import('node:http').ServerResponse} res - the response to complete.
|
|
48
|
+
* @param {number} status - the HTTP status.
|
|
49
|
+
* @param {{ ok: boolean }} payload - the envelope, success or failure.
|
|
50
|
+
*/
|
|
51
|
+
export function sendJson(res, status, payload) {
|
|
52
|
+
const body = JSON.stringify(payload)
|
|
53
|
+
res.writeHead(status, {
|
|
54
|
+
'content-type': 'application/json; charset=utf-8',
|
|
55
|
+
'content-length': Buffer.byteLength(body),
|
|
56
|
+
'cache-control': 'no-store',
|
|
57
|
+
})
|
|
58
|
+
res.end(body)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Answer with one admission rejection.
|
|
63
|
+
* @param {import('node:http').ServerResponse} res - the response to complete.
|
|
64
|
+
* @param {import('../http-admission.js').Rejection} rejection - the refusal.
|
|
65
|
+
*/
|
|
66
|
+
export function sendRejection(res, rejection) {
|
|
67
|
+
sendJson(res, rejection.status, { ok: false, code: rejection.code, error: rejection.message })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Trust surface consumed here; the browser-side connection package owns the full type. */
|
|
71
|
+
export function connectionOf(ctx) {
|
|
72
|
+
return Reflect.get(ctx, 'connection')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* This installation's development token, minted on first use.
|
|
77
|
+
*
|
|
78
|
+
* The file is owner-only and lives outside the repository. A web page cannot
|
|
79
|
+
* read it, which is what keeps the acceptance surface unreachable from a
|
|
80
|
+
* cross-site request even though the route itself is on loopback.
|
|
81
|
+
* @returns {Promise<string>} the token.
|
|
82
|
+
*/
|
|
83
|
+
export async function devToken() {
|
|
84
|
+
const path = join(dshHome(), 'wsl-desktop-dev-token')
|
|
85
|
+
try {
|
|
86
|
+
const existing = (await readFile(path, 'utf8')).trim()
|
|
87
|
+
if (existing.length > 0) return existing
|
|
88
|
+
// An empty file is unusual — treat it like absent and re-mint below.
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// ENOENT = first use. Any other read failure (ACL, AV lock, disk) must
|
|
91
|
+
// fail loud rather than silently rotate the credential and revoke every
|
|
92
|
+
// scripted caller.
|
|
93
|
+
if (error?.code !== 'ENOENT') throw error
|
|
94
|
+
}
|
|
95
|
+
const minted = randomBytes(32).toString('hex')
|
|
96
|
+
await mkdir(dirname(path), { recursive: true })
|
|
97
|
+
await writeFile(path, `${minted}\n`, { mode: 0o600 })
|
|
98
|
+
return minted
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether one request may use the acceptance surface.
|
|
103
|
+
* @param {import('node:http').IncomingMessage} req - the request.
|
|
104
|
+
* @param {{ developerTools: boolean }} config - resolved plugin config.
|
|
105
|
+
* @returns {Promise<boolean>} true for a caller presenting this installation's token.
|
|
106
|
+
*/
|
|
107
|
+
export async function isDeveloperCaller(req, config) {
|
|
108
|
+
if (config.developerTools !== true) return false
|
|
109
|
+
return tokenMatches(req.headers[DEV_TOKEN_HEADER], await devToken(), timingSafeEqual)
|
|
110
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preset derivation helpers: variant ids, base rows, and the shell-spec seam.
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from `lib/index.js` (C3). Only the PURE helpers moved: the
|
|
5
|
+
* two that materialise and refresh presets stay in the entry file because they
|
|
6
|
+
* read and write the plugin's module-level state (`presetState`, `currentGeneration`,
|
|
7
|
+
* `hostCtx`, `hostReady`). Moving those would mean shipping that state or injecting
|
|
8
|
+
* it, which is a rewrite, not a move — see the plan's "Out of Scope".
|
|
9
|
+
* @module dsh-wsl-desktop/host/presets
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { dshHome } from './http.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The prefix every generated variant id carries.
|
|
19
|
+
*
|
|
20
|
+
* Not exported from `index.js` by accident: the same literal is what the route
|
|
21
|
+
* filters on, so a change here is a wire-visible change.
|
|
22
|
+
*/
|
|
23
|
+
export const GENERATED_PREFIX = 'wsl-'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The stable id for one base preset's WSL variant.
|
|
27
|
+
* @param {{ id: string }} preset - the resolved base preset.
|
|
28
|
+
* @returns {string} the variant id.
|
|
29
|
+
*/
|
|
30
|
+
export function variantIdFor(preset) {
|
|
31
|
+
return preset.id.startsWith(GENERATED_PREFIX) ? preset.id : `${GENERATED_PREFIX}${preset.id}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Locate the directory a preset was discovered under.
|
|
36
|
+
*
|
|
37
|
+
* A preset may name a row by a path relative to its own directory, so the
|
|
38
|
+
* variant needs that base to rewrite the name to something that still resolves
|
|
39
|
+
* from where the variant lives.
|
|
40
|
+
*
|
|
41
|
+
* Both spellings are historical: the package is `@deepseek-ai/dsh-agent-preset`
|
|
42
|
+
* (singular — the plural name never existed, so this resolve always threw), and
|
|
43
|
+
* since the presets moved into profile YAML declarations the package ships no
|
|
44
|
+
* `presets/<id>/agent.cordis.yml` tree at all. Neither branch is expected to
|
|
45
|
+
* hit on a current desktop; a base preset that DOES use relative row names
|
|
46
|
+
* therefore has no resolvable source directory, and `buildVariantPlugins`
|
|
47
|
+
* leaves the name as it found it. This stays a lookup rather than a guess so
|
|
48
|
+
* the failure mode is unchanged if a deployment ever ships that tree again.
|
|
49
|
+
* @param {string} id - preset id.
|
|
50
|
+
* @returns {string | undefined} the directory, or undefined when it cannot be found.
|
|
51
|
+
*/
|
|
52
|
+
export function sourceDirFor(id) {
|
|
53
|
+
const local = join(dshHome(), '.agent-presets', id)
|
|
54
|
+
if (existsSync(join(local, 'agent.cordis.yml'))) return local
|
|
55
|
+
try {
|
|
56
|
+
const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-agent-preset/package.json'))
|
|
57
|
+
const shipped = join(dirname(manifest), 'presets', id)
|
|
58
|
+
if (existsSync(join(shipped, 'agent.cordis.yml'))) return shipped
|
|
59
|
+
} catch {
|
|
60
|
+
// A deployment without the preset package leaves relative names as they are.
|
|
61
|
+
}
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read a base preset's declared plugin rows as OBJECTS.
|
|
67
|
+
*
|
|
68
|
+
* 0.1.7 keeps each declaration's `PresetDefinition` — with `plugins` as entry
|
|
69
|
+
* objects — in a registry map that is TS-private but reachable in-process
|
|
70
|
+
* (ctx.get returns the service instance). No YAML parser is needed on this
|
|
71
|
+
* path; the public readDocument only exposes a re-dumped text form.
|
|
72
|
+
* @param {object} presets - the agentPresets service.
|
|
73
|
+
* @param {string} id - base preset id.
|
|
74
|
+
* @returns {readonly object[] | null} the declared rows, or null when unreachable.
|
|
75
|
+
*/
|
|
76
|
+
export function basePluginsOf(presets, id) {
|
|
77
|
+
const plugins = presets.definitions?.get?.(id)?.config?.plugins
|
|
78
|
+
return Array.isArray(plugins) ? plugins : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run a shell spec through whichever seam the host exposes, and always hand back a
|
|
83
|
+
* handle whose `result()` is awaitable.
|
|
84
|
+
*
|
|
85
|
+
* 0.1.7 hosts expose `execute`, which resolves with a live handle; older ones have
|
|
86
|
+
* only `run`, whose settled result carries no `result()` projection. Both selftest
|
|
87
|
+
* steps used to spell that dual dispatch themselves, and the second one called
|
|
88
|
+
* `.result()` on `run`'s settled result — a TypeError on exactly the hosts that
|
|
89
|
+
* branch exists to support. One owner, so the two steps cannot drift again.
|
|
90
|
+
* @param {object} shell - the realm's shell service.
|
|
91
|
+
* @param {object} spec - the resolved spec to run.
|
|
92
|
+
* @returns {Promise<{ result: () => Promise<object> }>} a result()-able handle.
|
|
93
|
+
*/
|
|
94
|
+
export function runShellSpec(shell, spec) {
|
|
95
|
+
if (typeof shell.execute === 'function') return shell.execute(spec)
|
|
96
|
+
return shell.run(spec).then((settled) => ({ result: async () => settled }))
|
|
97
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's module-level state, named and grouped.
|
|
3
|
+
*
|
|
4
|
+
* The route dispatcher calls `METHODS` without carrying the plugin's config, so
|
|
5
|
+
* the context, the configured `wsl.exe` path and the preset lifecycle cannot be
|
|
6
|
+
* parameters. They were loose module-level `let`s in `lib/index.js` (seven of them,
|
|
7
|
+
* counting the binding log and its cap); this module gives
|
|
8
|
+
* each group one owner, so a reader can see what is shared and why.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here is a getter/setter pair: the objects are mutable, and every call
|
|
11
|
+
* site reads a field. That keeps the move mechanical — `hostCtx.get(x)` became
|
|
12
|
+
* `hostSession.ctx.get(x)`.
|
|
13
|
+
* @module dsh-wsl-desktop/host/state
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The plugin context and the config it was applied with, as route methods see them. */
|
|
17
|
+
export const hostSession = {
|
|
18
|
+
/** @type {import('@deepseek-ai/cordis').Context | undefined} */
|
|
19
|
+
ctx: undefined,
|
|
20
|
+
/** The configured `wsl.exe` path; the default a fresh install runs with. */
|
|
21
|
+
wslPath: 'wsl.exe',
|
|
22
|
+
/** Whether the preset registry has been reached; `bootstrap` waits briefly for it. */
|
|
23
|
+
ready: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The preset registration lifecycle. */
|
|
27
|
+
export const presetRun = {
|
|
28
|
+
/** @type {{ status: string } & Record<string, unknown>} */
|
|
29
|
+
state: { status: 'pending' },
|
|
30
|
+
/** @type {{ disposed: boolean, disposers: Map<string, Function> } | null} */
|
|
31
|
+
generation: null,
|
|
32
|
+
/** Serialises refreshes so boot-time and developer-token calls cannot interleave. */
|
|
33
|
+
mutex: Promise.resolve(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Most binding decisions kept for inspection. */
|
|
37
|
+
export const BINDING_LOG_LIMIT = 50
|
|
38
|
+
|
|
39
|
+
/** The bounded record of preset-binding decisions, reported through the route. */
|
|
40
|
+
export const bindingTape = (() => {
|
|
41
|
+
/** @type {object[]} */
|
|
42
|
+
const log = []
|
|
43
|
+
return {
|
|
44
|
+
log,
|
|
45
|
+
/**
|
|
46
|
+
* Record one decision, dropping the oldest past the cap.
|
|
47
|
+
*
|
|
48
|
+
* The log is captured by the closure rather than read off `this`: a caller that
|
|
49
|
+
* destructures the method (`const { record } = bindingTape`) would otherwise get
|
|
50
|
+
* `TypeError: this.log is undefined` at the first write.
|
|
51
|
+
* @param {object} entry - the decision to record.
|
|
52
|
+
*/
|
|
53
|
+
record(entry) {
|
|
54
|
+
log.push(entry)
|
|
55
|
+
if (log.length > BINDING_LOG_LIMIT) log.splice(0, log.length - BINDING_LOG_LIMIT)
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
})()
|