dsh-wsl-desktop 0.7.2 → 0.7.4

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
@@ -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
  /**
@@ -102,6 +117,7 @@ window.__ModuleLoader__.load({
102
117
  * The catalog is bilingual, so the dialog can render a host failure in the
103
118
  * operator's language without a translation build step.
104
119
  */
120
+ // ── 2. copy, theme and shared node helpers ─────────────────────────────
105
121
  const LOCALE = typeof navigator !== 'undefined' && /^zh/i.test(String(navigator.language ?? '')) ? 'zh' : 'en'
106
122
 
107
123
  /**
@@ -316,6 +332,7 @@ window.__ModuleLoader__.load({
316
332
  * @param {object|null} envelope - the route's JSON body, when it parsed.
317
333
  * @returns {Error} the failure, with `code` and `params` attached.
318
334
  */
335
+ // ── 3. route calls and failure rendering ─────────────────────────────
319
336
  function routeFailure(method, status, envelope) {
320
337
  const error = new Error(envelope?.error ?? `${method} failed (HTTP ${status})`)
321
338
  error.code = typeof envelope?.code === 'string' ? envelope.code : status === 0 ? 'transport' : 'internal'
@@ -733,6 +750,7 @@ window.__ModuleLoader__.load({
733
750
  * so lastIndexOf returned -1 and slice(0, -1) gave 'hom'/'tm' — the host
734
751
  * listed /tmp while the "up" button navigated to /tm.
735
752
  */
753
+ // ── 4. the W workspace picker dialog ─────────────────────────────
736
754
  function parentOf(path) {
737
755
  const absolute = path.startsWith('/') ? path : `/${path}`
738
756
  const trimmed = absolute.replace(/\/+$/, '')
@@ -1499,6 +1517,7 @@ window.__ModuleLoader__.load({
1499
1517
  }
1500
1518
 
1501
1519
  /** The injected services the picker and its trigger read. */
1520
+ // ── 5. the companion button and workspace rows ─────────────────────────────
1502
1521
  const companion = { button: null, fallback: null, trigger: null, observer: null, scheduled: false, disposed: false }
1503
1522
 
1504
1523
  /** Whether the missing-workspace-source warning was already printed. */
@@ -2053,6 +2072,8 @@ window.__ModuleLoader__.load({
2053
2072
  }
2054
2073
  }
2055
2074
 
2075
+
2076
+ // ── 6. module registration and dispose ─────────────────────────────
2056
2077
  return {
2057
2078
  // `remote.session` is the namespace the create request travels through
2058
2079
  // (see `createBoundSession`); the harness's own client plugins inject it
@@ -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
+ })()