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 +1 -0
- package/README.md +1 -0
- package/lib/client.js +21 -0
- 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 +132 -280
- package/lib/wsl/confinement.js +18 -2
- package/lib/wsl/distros.js +190 -0
- package/lib/wsl/dsh-wsl-confine.sh +37 -4
- package/lib/wsl/fs.js +32 -0
- package/lib/wsl/preset.js +7 -0
- package/lib/wsl/proc.js +182 -0
- package/lib/wsl/sandbox.js +11 -4
- package/lib/wsl/shell.js +2 -1
- package/lib/wsl/subprocess.js +2 -1
- 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
|
/**
|
|
@@ -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
|
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
|
+
})()
|