@chronosun/dsh-wsl 0.0.0-stage → 1.5.0

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/src/broker.mjs ADDED
@@ -0,0 +1,373 @@
1
+ // One WslBroker per (distro, user), shared by every session and workspace.
2
+ // Plain ESM, no DSH dependency — testable on its own (see test/broker.test.mjs).
3
+ import { spawn } from 'node:child_process'
4
+ import { EventEmitter } from 'node:events'
5
+ import { createHash } from 'node:crypto'
6
+ import { homedir } from 'node:os'
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ const HELPER_VERSION = 1
11
+
12
+ /**
13
+ * Enter the Linux world with a PATH that cannot resolve Windows binaries.
14
+ * Measured on this machine: without this, every wsl.exe-launched process sees
15
+ * ~20 `/mnt/...` entries (Windows node, npm, git, rg, powershell), and
16
+ * `env PATH=` / `env -i` do NOT survive WSL's interop PATH rewrite — only an
17
+ * in-process assignment before exec does.
18
+ */
19
+ export const EXEC_WRAPPER = `#!/bin/bash
20
+ PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
21
+ export PATH
22
+ while [ "$1" = "-e" ]; do export "$2" || exit 125; shift 2; done
23
+ exec "$@"
24
+ `
25
+
26
+ /** Parse a `\\wsl.localhost\<distro>\<path>` (or `\\wsl$\...`) Windows path. */
27
+ export function parseWslUnc(winPath) {
28
+ const m = /^\\\\[?]?(wsl\.localhost|wsl\$)\\([^\\]+)\\(.*)$/i.exec(winPath)
29
+ if (!m) return null
30
+ return { distro: m[2], linuxPath: '/' + m[3].replace(/\\/g, '/') }
31
+ }
32
+
33
+ export function linuxToUnc(distro, linuxPath) {
34
+ return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
35
+ }
36
+
37
+ /** Binaries on a Windows drive are interop, not Linux: never execute them. */
38
+ export function isInteropBinary(p) {
39
+ return /^\/mnt\/[a-z]\//i.test(p)
40
+ }
41
+
42
+ /**
43
+ * Keep the distribution's login PATH but drop every `/mnt/*` entry. The login
44
+ * PATH is what makes user toolchains (`~/.local/bin`, nvm, cargo, …) visible;
45
+ * dropping `/mnt` is what keeps Windows interop binaries unreachable.
46
+ */
47
+ export function cleanLoginPath(raw) {
48
+ // A login profile may print a banner; the PATH itself is the last line.
49
+ const line = String(raw ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? ''
50
+ const entries = line
51
+ .split(':')
52
+ .map((entry) => entry.trim())
53
+ .filter(Boolean)
54
+ .filter((entry) => !/^\/mnt(\/|$)/i.test(entry))
55
+ for (const dir of ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin']) {
56
+ if (!entries.includes(dir)) entries.push(dir)
57
+ }
58
+ return [...new Set(entries)].join(':') || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
59
+ }
60
+
61
+ export class WslBroker extends EventEmitter {
62
+ #distro
63
+ #user
64
+ #idleMs
65
+ #helperSource
66
+ #child = null
67
+ #state = 'detached'
68
+ #buf = ''
69
+ #seq = 0
70
+ #pending = new Map()
71
+ #connecting = null
72
+ #retains = new Map()
73
+ #idleTimer = null
74
+ #nodePath = null
75
+ #helperPath = null
76
+ #cleanPath = null
77
+ #lastError = null
78
+ #retried = false
79
+ #forceProbe = false
80
+ #hello = null
81
+
82
+ constructor({ distro, user = '', idleMs = 15 * 60_000, helperSource }) {
83
+ super()
84
+ this.#distro = distro
85
+ this.#user = user
86
+ this.#idleMs = idleMs
87
+ this.#helperSource = helperSource
88
+ }
89
+
90
+ /** Apply a new idle timeout to an already-created broker. */
91
+ setIdleMs(ms) {
92
+ const n = Number(ms)
93
+ if (!Number.isFinite(n) || n < 0) return
94
+ this.#idleMs = n
95
+ this.#armIdleIfIdle()
96
+ }
97
+
98
+ get state() { return this.#state }
99
+ get hello() { return this.#hello }
100
+ get refcount() { return [...this.#retains.values()].reduce((a, b) => a + b, 0) }
101
+ /**
102
+ * The distribution's login-shell PATH without `/mnt/*` entries. Tools use it
103
+ * so a user-space toolchain (`~/.local/bin`, cargo, …) stays reachable while
104
+ * Windows interop binaries stay unreachable. Resolved on connect.
105
+ */
106
+ get cleanPath() { return this.#cleanPath }
107
+ get info() {
108
+ return { distro: this.#distro, user: this.#user, state: this.#state, refcount: this.refcount, lastError: this.#lastError }
109
+ }
110
+
111
+ #setState(next, detail) {
112
+ if (this.#state === next) return
113
+ this.#state = next
114
+ this.emit('state', { ...this.info, detail })
115
+ }
116
+
117
+ /** Park a reference so the idle timer cannot fire while work is in flight. */
118
+ retain(reason) {
119
+ this.#retains.set(reason, (this.#retains.get(reason) ?? 0) + 1)
120
+ this.#disarmIdle()
121
+ return () => this.release(reason)
122
+ }
123
+
124
+ release(reason) {
125
+ const n = (this.#retains.get(reason) ?? 0) - 1
126
+ if (n <= 0) this.#retains.delete(reason)
127
+ else this.#retains.set(reason, n)
128
+ this.#armIdleIfIdle()
129
+ }
130
+
131
+ #disarmIdle() {
132
+ if (this.#idleTimer) { clearTimeout(this.#idleTimer); this.#idleTimer = null }
133
+ }
134
+
135
+ #armIdleIfIdle() {
136
+ this.#disarmIdle()
137
+ if (this.#idleMs <= 0 || this.#state !== 'ready' || this.refcount > 0) return
138
+ this.#idleTimer = setTimeout(() => {
139
+ this.#idleTimer = null
140
+ this.disconnect('idle').catch(() => {})
141
+ }, this.#idleMs)
142
+ this.#idleTimer.unref?.()
143
+ }
144
+
145
+ /** Idempotent, single-flight connect. */
146
+ async connect() {
147
+ if (this.#state === 'ready') return this.info
148
+ if (this.#connecting) return this.#connecting
149
+ this.#connecting = this.#doConnect().finally(() => { this.#connecting = null })
150
+ return this.#connecting
151
+ }
152
+
153
+ async #doConnect() {
154
+ this.#setState('starting')
155
+ this.#lastError = null
156
+ try {
157
+ await this.#resolveCleanPath()
158
+ const { node, helper } = await this.#ensureHelper()
159
+ const hello = await this.#spawnHelper(node, helper)
160
+ this.#hello = hello
161
+ this.#setState('ready', hello)
162
+ this.#armIdleIfIdle()
163
+ return this.info
164
+ } catch (error) {
165
+ // A stale cache entry (distro reinstall, nvm switch) must self-heal once
166
+ // with a real probe before the error reaches the caller.
167
+ if (!this.#forceProbe && this.#readCache(this.#hashOfSource())) {
168
+ this.#forceProbe = true
169
+ this.#nodePath = null
170
+ this.#helperPath = null
171
+ return this.#doConnect()
172
+ }
173
+ this.#lastError = error.message
174
+ this.#setState('error', { message: error.message })
175
+ throw error
176
+ }
177
+ }
178
+
179
+ #spawnHelper(node, helper) {
180
+ return new Promise((resolve, reject) => {
181
+ const args = [...this.#distroArgs(), ...this.#userArgs()]
182
+ args.push('--', node, helper)
183
+ const child = spawn('wsl.exe', args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
184
+ this.#child = child
185
+ let settled = false
186
+ const fail = (err) => { if (!settled) { settled = true; reject(err) } }
187
+ const timer = setTimeout(() => fail(new Error('helper handshake timed out after 15s')), 15_000)
188
+ child.on('error', fail)
189
+ child.on('exit', (code, signal) => {
190
+ clearTimeout(timer)
191
+ this.#onExit(code, signal)
192
+ if (!settled) fail(new Error(`helper exited before handshake (code=${code} signal=${signal})`))
193
+ })
194
+ child.stdout.on('data', (d) => { this.#buf += d.toString('utf8'); this.#drain() })
195
+ child.stderr.on('data', (d) => this.emit('stderr', d.toString('utf8')))
196
+ child.stdin.on('error', () => {})
197
+ this.once('hello', (h) => {
198
+ clearTimeout(timer)
199
+ settled = true
200
+ resolve(h)
201
+ })
202
+ })
203
+ }
204
+
205
+ #onExit(code, signal) {
206
+ const hadChild = this.#child !== null
207
+ this.#child = null
208
+ if (!hadChild) return
209
+ for (const [, p] of this.#pending) p.reject(new Error(`WSL helper exited (code=${code} signal=${signal})`))
210
+ this.#pending.clear()
211
+ this.#hello = null
212
+ if (this.#state === 'stopping' || this.#state === 'detached') return
213
+ this.#lastError = `helper exited unexpectedly (code=${code} signal=${signal})`
214
+ this.#setState('error', { message: this.#lastError })
215
+ if (this.refcount > 0 && !this.#retried) {
216
+ this.#retried = true
217
+ setTimeout(() => { this.#retried = false; this.connect().catch(() => {}) }, 500).unref?.()
218
+ }
219
+ }
220
+
221
+ #drain() {
222
+ let i
223
+ while ((i = this.#buf.indexOf('\n')) >= 0) {
224
+ const line = this.#buf.slice(0, i)
225
+ this.#buf = this.#buf.slice(i + 1)
226
+ if (!line.trim()) continue
227
+ let msg
228
+ try { msg = JSON.parse(line) } catch { continue }
229
+ if (msg.event === 'hello') { this.emit('hello', msg.result); continue }
230
+ const p = this.#pending.get(msg.id)
231
+ if (!p) continue
232
+ this.#pending.delete(msg.id)
233
+ msg.error ? p.reject(Object.assign(new Error(msg.error.message ?? String(msg.error)), msg.error)) : p.resolve(msg.result)
234
+ }
235
+ }
236
+
237
+ async call(op, args = {}, { signal } = {}) {
238
+ await this.connect()
239
+ if (signal?.aborted) throw new Error('aborted')
240
+ this.#disarmIdle()
241
+ try {
242
+ return await this.#roundTrip(op, args, signal)
243
+ } finally {
244
+ this.#armIdleIfIdle()
245
+ }
246
+ }
247
+
248
+ #roundTrip(op, args, signal) {
249
+ return new Promise((resolve, reject) => {
250
+ const id = ++this.#seq
251
+ const onAbort = () => { this.#pending.delete(id); reject(new Error('aborted')) }
252
+ this.#pending.set(id, {
253
+ resolve: (v) => { signal?.removeEventListener('abort', onAbort); resolve(v) },
254
+ reject: (e) => { signal?.removeEventListener('abort', onAbort); reject(e) },
255
+ })
256
+ signal?.addEventListener('abort', onAbort, { once: true })
257
+ try {
258
+ this.#child.stdin.write(JSON.stringify({ id, op, ...args }) + '\n')
259
+ } catch (error) {
260
+ this.#pending.delete(id)
261
+ reject(error)
262
+ }
263
+ })
264
+ }
265
+
266
+ async #resolveCleanPath() {
267
+ if (this.#cleanPath !== null) return
268
+ let raw = ''
269
+ try {
270
+ // `printenv` avoids `$` in wsl.exe's argv (it pre-expands `$VAR`).
271
+ raw = await this.#wsl('printenv PATH')
272
+ } catch {}
273
+ this.#cleanPath = cleanLoginPath(raw)
274
+ }
275
+
276
+ async #ensureHelper() {
277
+ if (this.#nodePath && this.#helperPath) return { node: this.#nodePath, helper: this.#helperPath }
278
+ const hash = this.#hashOfSource()
279
+ if (!this.#forceProbe) {
280
+ const cached = this.#readCache(hash)
281
+ if (cached) {
282
+ this.#nodePath = cached.node
283
+ this.#helperPath = cached.helper
284
+ return { node: cached.node, helper: cached.helper }
285
+ }
286
+ }
287
+ this.#forceProbe = false
288
+ const node = await this.#wsl('command -v node')
289
+ if (!node) throw new Error('WSL 发行版里没有 node(或只有 Windows 的 node.exe)。请安装 Node.js 22+,或等待 python3 兜底实现。')
290
+ if (isInteropBinary(node)) throw new Error(`node 解析到了 Windows 的二进制(${node}),拒绝使用`)
291
+ // Bootstrap through stdin: no /mnt, no package manager, no shared folder.
292
+ const helper = `$HOME/.dsh-wsl/broker-${hash}.mjs`
293
+ const present = await this.#wsl(`test -f ${helper} && echo yes`)
294
+ if (present !== 'yes') {
295
+ await this.#wslPipe(`mkdir -p $HOME/.dsh-wsl && cat > ${helper}`, this.#helperSource)
296
+ }
297
+ this.#nodePath = node
298
+ this.#helperPath = helper
299
+ this.#writeCache({ node, helper, hash })
300
+ return { node, helper }
301
+ }
302
+
303
+ #distroArgs() { return this.#distro ? ['-d', this.#distro] : [] }
304
+ #userArgs() { return this.#user ? ['-u', this.#user] : [] }
305
+
306
+ #cacheFile() {
307
+ return join(homedir(), '.dsh', 'wsl-broker-cache.json')
308
+ }
309
+
310
+ #hashOfSource() {
311
+ return createHash('sha256').update(`v${HELPER_VERSION}\n` + this.#helperSource).digest('hex').slice(0, 16)
312
+ }
313
+
314
+ #readCache(hash) {
315
+ try {
316
+ const all = JSON.parse(readFileSync(this.#cacheFile(), 'utf8'))
317
+ const entry = all[`${this.#distro}|${this.#user}`]
318
+ if (entry?.v === HELPER_VERSION && entry.h === hash && entry.node && entry.helper) return entry
319
+ } catch {}
320
+ return null
321
+ }
322
+
323
+ #writeCache({ node, helper, hash }) {
324
+ try {
325
+ const file = this.#cacheFile()
326
+ mkdirSync(join(homedir(), '.dsh'), { recursive: true })
327
+ let all = {}
328
+ try { all = JSON.parse(readFileSync(file, 'utf8')) } catch {}
329
+ all[`${this.#distro}|${this.#user}`] = { v: HELPER_VERSION, h: hash, node, helper, at: new Date().toISOString() }
330
+ writeFileSync(file, JSON.stringify(all, null, 2))
331
+ } catch {}
332
+ }
333
+
334
+ #wsl(script) {
335
+ return new Promise((resolve, reject) => {
336
+ const args = [...this.#distroArgs(), ...this.#userArgs()]
337
+ args.push('--', 'bash', '-lc', script)
338
+ const child = spawn('wsl.exe', args, { windowsHide: true })
339
+ let out = ''
340
+ child.stdout.on('data', (d) => (out += d.toString('utf8')))
341
+ child.on('error', reject)
342
+ child.on('close', () => resolve(out.trim()))
343
+ })
344
+ }
345
+
346
+ #wslPipe(script, input) {
347
+ return new Promise((resolve, reject) => {
348
+ const args = [...this.#distroArgs(), ...this.#userArgs()]
349
+ args.push('--', 'bash', '-lc', script)
350
+ const child = spawn('wsl.exe', args, { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] })
351
+ let err = ''
352
+ child.stderr.on('data', (d) => (err += d.toString('utf8')))
353
+ child.on('error', reject)
354
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`bootstrap failed (${code}): ${err}`))))
355
+ child.stdin.end(input)
356
+ })
357
+ }
358
+
359
+ async disconnect(reason = 'explicit') {
360
+ if (this.#state === 'detached' || this.#state === 'stopping') return
361
+ this.#setState('stopping', reason)
362
+ this.#disarmIdle()
363
+ const child = this.#child
364
+ if (child) {
365
+ try { child.stdin.write(JSON.stringify({ id: ++this.#seq, op: 'shutdown' }) + '\n') } catch {}
366
+ await new Promise((r) => setTimeout(r, 80))
367
+ try { child.kill() } catch {}
368
+ }
369
+ this.#child = null
370
+ this.#hello = null
371
+ this.#setState('detached', reason)
372
+ }
373
+ }
@@ -0,0 +1,92 @@
1
+ // Keep the diff card's Host-facing paths intact, but never show a Windows UNC
2
+ // string in a WSL session's labels. The summary's `path` and `cwd` are used by
3
+ // `/api/changes.open` to resolve the file on the Host, so only `display` is
4
+ // translated; relative display paths are already Linux-neutral.
5
+ import { linuxPathFromUnc } from './preset-default.mjs'
6
+
7
+ export const WSL_PRESET = 'wsl'
8
+ const PATCH = Symbol.for('dsh-wsl/changes-paths')
9
+
10
+ /** `/home/...` for an absolute WSL UNC display path; otherwise the input. */
11
+ export function mapDisplayPath(value) {
12
+ const linux = linuxPathFromUnc(value)
13
+ return linux ?? value
14
+ }
15
+
16
+ function unwrap(value) {
17
+ if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
18
+ try { return value[Symbol.for('cordis.original')] ?? value } catch { return value }
19
+ }
20
+
21
+ /**
22
+ * Install the display-path translation on the Host `workspaceChanges` service.
23
+ * Only summaries/diffs owned by a WSL-preset Session are touched.
24
+ *
25
+ * @returns `{ installed, dispose }`; `installed` is a live getter.
26
+ */
27
+ export function installWorkspaceChangesPaths(ctx) {
28
+ const disposers = []
29
+ let installed = false
30
+
31
+ const presetOfSession = (sessionId) => {
32
+ try {
33
+ const agent = ctx.get('agents')?.get?.(sessionId)
34
+ return ctx.get('agentPresets')?.presetForSession?.(agent?.session)
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ const install = (service) => {
41
+ if (installed) return true
42
+ const raw = unwrap(service)
43
+ if (!raw || typeof raw.summary !== 'function' || typeof raw.diff !== 'function') return false
44
+ if (raw[PATCH]) { installed = true; return true }
45
+
46
+ const originalSummary = raw.summary
47
+ const originalDiff = raw.diff
48
+ const summaryDescriptor = Object.getOwnPropertyDescriptor(raw, 'summary')
49
+ const diffDescriptor = Object.getOwnPropertyDescriptor(raw, 'diff')
50
+ const summary = function summaryPatched(sessionId, seq) {
51
+ const value = originalSummary.call(raw, sessionId, seq)
52
+ if (!value || presetOfSession(sessionId) !== WSL_PRESET || !Array.isArray(value.files)) return value
53
+ return {
54
+ ...value,
55
+ files: value.files.map((file) => ({ ...file, display: mapDisplayPath(file.display) })),
56
+ }
57
+ }
58
+ const diff = async function diffPatched(sessionId, seq, index, signal) {
59
+ const value = await originalDiff.call(raw, sessionId, seq, index, signal)
60
+ if (!value || presetOfSession(sessionId) !== WSL_PRESET) return value
61
+ return { ...value, display: mapDisplayPath(value.display) }
62
+ }
63
+
64
+ try {
65
+ Object.defineProperty(raw, 'summary', { configurable: true, enumerable: false, writable: true, value: summary })
66
+ Object.defineProperty(raw, 'diff', { configurable: true, enumerable: false, writable: true, value: diff })
67
+ } catch {
68
+ return false
69
+ }
70
+ const dispose = () => {
71
+ try { if (summaryDescriptor) Object.defineProperty(raw, 'summary', summaryDescriptor) } catch {}
72
+ try { if (diffDescriptor) Object.defineProperty(raw, 'diff', diffDescriptor) } catch {}
73
+ try { delete raw[PATCH] } catch {}
74
+ }
75
+ Object.defineProperty(raw, PATCH, { configurable: true, enumerable: false, writable: true, value: dispose })
76
+ disposers.push(dispose)
77
+ installed = true
78
+ return true
79
+ }
80
+
81
+ const current = ctx.get('workspaceChanges')
82
+ if (current) install(current)
83
+ else ctx.on('internal/service', (name, value) => { if (name === 'workspaceChanges') install(value) })
84
+
85
+ return {
86
+ get installed() { return installed },
87
+ dispose() {
88
+ for (const dispose of disposers.splice(0)) dispose()
89
+ installed = false
90
+ },
91
+ }
92
+ }
@@ -0,0 +1,47 @@
1
+ // Bounded, offset-based output collectors — matches the ctx.subprocess
2
+ // "reads are non-consuming" contract: independent readers see the same bytes.
3
+ export class Collector {
4
+ #chunks = []
5
+ #bytes = 0
6
+ #maxBytes
7
+ #lossy = false
8
+ #text = null
9
+
10
+ constructor(maxBytes) {
11
+ this.#maxBytes = maxBytes
12
+ }
13
+
14
+ get lossy() { return this.#lossy }
15
+
16
+ write(chunk) {
17
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
18
+ this.#text = null
19
+ const room = this.#maxBytes - this.#bytes
20
+ if (buf.length <= room) {
21
+ this.#chunks.push(buf)
22
+ this.#bytes += buf.length
23
+ return
24
+ }
25
+ if (room > 0) {
26
+ this.#chunks.push(buf.subarray(0, room))
27
+ this.#bytes += room
28
+ }
29
+ this.#lossy = true
30
+ }
31
+
32
+ #string() {
33
+ if (this.#text === null) this.#text = Buffer.concat(this.#chunks).toString('utf8')
34
+ return this.#text
35
+ }
36
+
37
+ readFrom(fromByte) {
38
+ const text = this.#string()
39
+ const total = Buffer.byteLength(text)
40
+ if (fromByte >= total) return { text: '', nextOffset: total, lossy: this.#lossy }
41
+ // Offsets are byte offsets; slice by bytes then decode, so multi-byte
42
+ // characters never split the returned window at the wrong place.
43
+ const buf = Buffer.from(text)
44
+ const slice = buf.subarray(fromByte).toString('utf8')
45
+ return { text: slice, nextOffset: total, lossy: this.#lossy }
46
+ }
47
+ }