dsh-wsl-desktop 0.2.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/lib/index.js ADDED
@@ -0,0 +1,1067 @@
1
+ /**
2
+ * Host half of `dsh-wsl-desktop`.
3
+ *
4
+ * Two jobs:
5
+ *
6
+ * 1. Answer the browser's WSL questions over the same-origin route — the
7
+ * browser cannot run `wsl.exe`.
8
+ * 2. Materialize the WSL agent presets into the user preset root. A session's
9
+ * execution world is chosen by its preset, so this is what lets one Desktop
10
+ * instance run Windows workspaces and WSL workspaces side by side.
11
+ * @module dsh-wsl-desktop
12
+ */
13
+
14
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
15
+ import { existsSync } from 'node:fs'
16
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
17
+ import { homedir } from 'node:os'
18
+ import { dirname, join } from 'node:path'
19
+ import { fileURLToPath, pathToFileURL } from 'node:url'
20
+ import z from '@deepseek-ai/schemastery'
21
+ import {
22
+ MAX_BODY_BYTES, DEV_TOKEN_HEADER, bodyAdmission, dispatchAdmission, methodAdmission, preflight, tokenMatches,
23
+ } from './http-admission.js'
24
+ import { buildVariantPlugins } from './wsl/preset.js'
25
+ import { setLocalSubprocess } from './wsl/host-refs.js'
26
+ import { checkLinuxPath, defaultDistro, listDistros, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
27
+ import { joinWslUnc, parseWslUnc, windowsToMntPath } from './wsl/paths.js'
28
+
29
+ /** Loader row identity, also used in diagnostics. */
30
+ export const name = 'wsl-desktop'
31
+
32
+ /**
33
+ * This plugin publishes a route, so it requires the route owner. `connection`
34
+ * carries the composition's trust fence: its Host/Origin check defeats DNS
35
+ * rebinding, and its browser authentication (the login-token cookie) gates every
36
+ * caller before any method runs.
37
+ */
38
+ export const inject = ['webServer', 'connection']
39
+
40
+ /** Plugin config. */
41
+ export const Config = z.object({
42
+ /**
43
+ * Same-origin endpoint the browser half posts to. Configurable so a second
44
+ * generation can be mounted beside a running one, which is how the host half
45
+ * is exercised without restarting the application.
46
+ */
47
+ routePath: z.string().default('/wsl-desktop/api'),
48
+ /**
49
+ * Expose the acceptance surface — command execution, session self-test, preset
50
+ * regeneration — to a caller presenting this installation's development token.
51
+ * Off by default: the browser half needs only the read-only discovery methods,
52
+ * and the rest exist for verification rather than for the product.
53
+ */
54
+ developerTools: z.boolean().default(false),
55
+ })
56
+
57
+ /** Directory of this module, used to name the provider rows absolutely. */
58
+ const HERE = dirname(fileURLToPath(import.meta.url))
59
+
60
+ /** Prefix of every preset this plugin owns; its own outputs are never sources. */
61
+ const GENERATED_PREFIX = 'wsl-'
62
+
63
+ /**
64
+ * Module specifiers of the WSL providers, as the preset rows must name them.
65
+ * The 0.1.7 loader imports row names via `new URL(name, baseUrl)` or a bare
66
+ * `import(name)` — a backslash Windows path fails both, so the rows carry
67
+ * `file://` URL spellings, which import() accepts on every generation.
68
+ */
69
+ const SUBPROCESS_MODULE = pathToFileURL(join(HERE, 'wsl', 'subprocess.js')).href
70
+ const SHELL_MODULE = pathToFileURL(join(HERE, 'wsl', 'shell.js')).href
71
+ const FS_MODULE = pathToFileURL(join(HERE, 'wsl', 'fs.js')).href
72
+
73
+ /** Last preset materialization outcome, reported through the route. */
74
+ let presetState = { status: 'pending' }
75
+
76
+ /** Recent preset-binding decisions, reported through the route. */
77
+ const bindingLog = []
78
+
79
+ /** Most binding decisions kept for inspection. */
80
+ const BINDING_LOG_LIMIT = 50
81
+
82
+ /** The plugin context, captured so route methods can reach host services. */
83
+ let hostCtx
84
+
85
+ /**
86
+ * The harness home that holds the user preset root.
87
+ * @returns {string} the resolved `$DSH_HOME`.
88
+ */
89
+ function dshHome() {
90
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh')
91
+ }
92
+
93
+ /**
94
+ * Resolve a location the caller supplied into one distribution plus Linux path.
95
+ * @param {unknown} location - UNC path, absolute Linux path, or Windows drive path.
96
+ * @param {string | undefined} distroHint - distribution to use for a non-UNC location.
97
+ * @returns {Promise<{ distro: string, linuxPath: string, uncPath: string }>} the resolved target.
98
+ * @throws Error when the location names no world.
99
+ */
100
+ async function resolveLocation(location, distroHint) {
101
+ if (typeof location !== 'string' || location.length === 0) throw new Error('缺少路径')
102
+ const unc = parseWslUnc(location)
103
+ if (unc !== null) return { ...unc, uncPath: joinWslUnc(unc.distro, unc.linuxPath) }
104
+ const linuxPath = location.startsWith('/') ? location : windowsToMntPath(location)
105
+ if (linuxPath === null) throw new Error(`路径既不是 WSL 路径也不是 Windows 路径:${location}`)
106
+ const distro = distroHint ?? await defaultDistro()
107
+ if (distro === undefined) throw new Error('无法确定 WSL 发行版:请显式指定发行版')
108
+ return { distro, linuxPath, uncPath: joinWslUnc(distro, linuxPath) }
109
+ }
110
+
111
+ /**
112
+ * Locate the directory a preset was discovered under.
113
+ *
114
+ * A preset may name a row by a path relative to its own directory, so the
115
+ * variant needs that base to rewrite the name to something that still resolves
116
+ * from where the variant lives.
117
+ * @param {string} id - preset id.
118
+ * @returns {string | undefined} the directory, or undefined when it cannot be found.
119
+ */
120
+ function sourceDirFor(id) {
121
+ const local = join(dshHome(), '.agent-presets', id)
122
+ if (existsSync(join(local, 'agent.cordis.yml'))) return local
123
+ try {
124
+ const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-agent-presets/package.json'))
125
+ const shipped = join(dirname(manifest), 'presets', id)
126
+ if (existsSync(join(shipped, 'agent.cordis.yml'))) return shipped
127
+ } catch {
128
+ // A deployment without the preset package leaves relative names as they are.
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ /** Disposers of the variants this plugin registered; the plugin owns their lifecycle. */
134
+ const variantDisposers = new Map()
135
+
136
+ /**
137
+ * Read a base preset's declared plugin rows as OBJECTS.
138
+ *
139
+ * 0.1.7 keeps each declaration's `PresetDefinition` — with `plugins` as entry
140
+ * objects — in a registry map that is TS-private but reachable in-process
141
+ * (ctx.get returns the service instance). No YAML parser is needed on this
142
+ * path; the public readDocument only exposes a re-dumped text form.
143
+ * @param {object} presets - the agentPresets service.
144
+ * @param {string} id - base preset id.
145
+ * @returns {readonly object[] | null} the declared rows, or null when unreachable.
146
+ */
147
+ function basePluginsOf(presets, id) {
148
+ const plugins = presets.definitions?.get?.(id)?.config?.plugins
149
+ return Array.isArray(plugins) ? plugins : null
150
+ }
151
+
152
+ /**
153
+ * Derive and register the WSL variant presets (0.1.7-native).
154
+ *
155
+ * Since 0.1.7 the registry learns presets from `register()` calls by declaring
156
+ * plugins; the on-disk generated directory is no longer consulted. For every
157
+ * base preset this plugin derives `wsl-<base>` and registers it; the returned
158
+ * disposer is kept so a plugin dispose (or a re-registration) withdraws the
159
+ * variant cleanly.
160
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
161
+ * @param {string | undefined} distro - distribution to pin.
162
+ * @param {{ disposed: boolean }} generation - this registration generation's
163
+ * lifecycle token; the plugin's teardown flips `disposed` synchronously
164
+ * before draining, and the loop re-checks it around every await so a
165
+ * disable landing mid-registration cannot orphan a variant whose disposer
166
+ * nobody owns.
167
+ * @returns {Promise<object>} the registration outcome for the route.
168
+ */
169
+ async function materializePresets(ctx, distro, generation) {
170
+ const presets = ctx.get('agentPresets')
171
+ if (presets === undefined) throw new Error('agentPresets 服务不可用')
172
+ // 版本门控:本插件的目标面是 0.1.7 的运行时注册架构。旧宿主(无
173
+ // readDocument)会在下面的派生循环里逐项失败——在这里一次性给出人话拒绝,
174
+ // 而不是让操作者从一堆静默降级里猜。
175
+ const hostCompat = { registry07: typeof presets.readDocument === 'function' }
176
+ if (hostCompat.registry07 !== true) {
177
+ throw new Error('需要 DSH Desktop 0.1.7+:当前宿主的 agentPresets 缺少 readDocument(0.1.6 及以下不受支持)。请升级桌面,或使用 0.1.6 兼容的插件版本。')
178
+ }
179
+ if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
180
+ const roster = await presets.list()
181
+ if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
182
+ const written = []
183
+ const skipped = []
184
+
185
+ // Withdraw the previous generation first: register() refuses duplicates.
186
+ for (const [id, dispose] of variantDisposers) {
187
+ try { await dispose() } catch { /* already gone */ }
188
+ variantDisposers.delete(id)
189
+ if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
190
+ }
191
+
192
+ for (const preset of roster) {
193
+ const id = typeof preset?.id === 'string' ? preset.id : undefined
194
+ if (id === undefined) continue
195
+ // Our own variants are outputs, never sources (wsl-wsl-standard, …).
196
+ if (id.startsWith(GENERATED_PREFIX)) {
197
+ skipped.push({ id, reason: '落在本插件的生成命名空间里,不作为派生源' })
198
+ continue
199
+ }
200
+ // A preset the registry reports as unmountable cannot be repaired by deriving.
201
+ if (typeof preset.broken === 'string' && preset.broken.length > 0) {
202
+ skipped.push({ id, broken: preset.broken })
203
+ continue
204
+ }
205
+ const basePlugins = basePluginsOf(presets, id)
206
+ if (basePlugins === null) {
207
+ skipped.push({ id, reason: '无法读取声明行对象(registry 内部结构变化)' })
208
+ continue
209
+ }
210
+ const variantId = `${GENERATED_PREFIX}${id}`
211
+ const display = typeof preset?.name === 'string' && preset.name.length > 0 ? preset.name : id
212
+ // The transform belongs INSIDE the per-preset try: a malformed base row
213
+ // must skip THIS preset, not abort the loop after earlier variants were
214
+ // already withdrawn (which would leave zero variants registered).
215
+ try {
216
+ const sourceDir = sourceDirFor(id)
217
+ const { plugins, removed } = buildVariantPlugins(basePlugins, {
218
+ subprocessPath: SUBPROCESS_MODULE,
219
+ shellPath: SHELL_MODULE,
220
+ fsPath: FS_MODULE,
221
+ ...(distro !== undefined ? { distro } : {}),
222
+ ...(sourceDir !== undefined ? { sourceDir } : {}),
223
+ })
224
+ const dispose = await presets.register({
225
+ id: variantId,
226
+ name: `WSL · ${display}`,
227
+ description: `在 WSL 发行版里执行(由 ${id} 派生)`,
228
+ plugins,
229
+ })
230
+ // The check-after-await → synchronous-set pair is atomic against the
231
+ // synchronous teardown (single-threaded JS): a disable landing during
232
+ // register() hands the just-obtained variant straight back to its
233
+ // disposer instead of orphaning it in the registry.
234
+ if (generation?.disposed) {
235
+ try { await dispose() } catch { /* already gone */ }
236
+ skipped.push({ id: variantId, reason: '插件已卸载,注册即撤回' })
237
+ continue
238
+ }
239
+ variantDisposers.set(variantId, dispose)
240
+ written.push({ id: variantId, from: id, removed })
241
+ } catch (error) {
242
+ const message = error instanceof Error ? error.message : String(error)
243
+ // A duplicate means a previous generation is still mounted (host without
244
+ // restart after a sync): report rather than fail the whole generation.
245
+ skipped.push({ id: variantId, reason: message })
246
+ }
247
+ }
248
+ return { written, skipped, hostCompat }
249
+ }
250
+
251
+ /** Serialises refreshPresets so boot-time and developer-token calls cannot interleave. */
252
+ let refreshMutex = Promise.resolve()
253
+
254
+ /** The live registration generation; teardown flips its disposed flag. */
255
+ let currentGeneration = null
256
+
257
+ /**
258
+ * Refresh the presets, recording the outcome for the route.
259
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
260
+ * @param {string | undefined} distro - distribution to pin.
261
+ * @param {{ disposed: boolean } | null} generation - this registration
262
+ * generation's lifecycle token (see materializePresets).
263
+ * @returns {Promise<object>} the new preset state.
264
+ */
265
+ async function refreshPresets(ctx, distro, generation = currentGeneration) {
266
+ const run = async () => {
267
+ presetState = { status: 'running' }
268
+ try {
269
+ const result = await materializePresets(ctx, distro, generation)
270
+ presetState = { status: 'ready', ...result }
271
+ } catch (error) {
272
+ presetState = { status: 'failed', error: error instanceof Error ? error.message : String(error) }
273
+ }
274
+ return presetState
275
+ }
276
+ const result = refreshMutex.then(run, run)
277
+ refreshMutex = result.then(() => {}, () => {})
278
+ return result
279
+ }
280
+
281
+ /**
282
+ * Methods the browser half may call while composing a workspace.
283
+ *
284
+ * This is the whole browser-reachable surface. Everything else in {@link METHODS}
285
+ * executes commands, creates sessions or rewrites the preset root, and is
286
+ * reachable only by a caller presenting the development token — the route is on
287
+ * loopback, so "the browser can reach it" is an authority decision, not a
288
+ * convenience.
289
+ */
290
+ const BROWSER_METHODS = new Set([
291
+ 'listDistros',
292
+ 'defaultDistro',
293
+ 'listDir',
294
+ 'checkPath',
295
+ 'resolve',
296
+ 'resolveHome',
297
+ 'wslPresetFor',
298
+ ])
299
+
300
+ /** The method table the route dispatches into. */
301
+ const METHODS = {
302
+ /**
303
+ * List the installed distributions.
304
+ * @returns {Promise<string[]>} distribution names.
305
+ */
306
+ listDistros: () => listDistros(),
307
+
308
+ /**
309
+ * Report the default distribution.
310
+ * @returns {Promise<{ distro: string | null }>} the registry default.
311
+ */
312
+ defaultDistro: async () => ({ distro: (await defaultDistro()) ?? null }),
313
+
314
+ /**
315
+ * List one Linux directory.
316
+ * @param {{ distro: string, path: string }} params - distribution and absolute Linux path.
317
+ * @returns {Promise<object>} the listing.
318
+ */
319
+ listDir: async ({ distro, path }) => listLinuxDir(distro, path ?? '/'),
320
+
321
+ /**
322
+ * Check one Linux path and report the workspace spelling it would take.
323
+ * @param {{ distro: string, path: string }} params - distribution and absolute Linux path.
324
+ * @returns {Promise<object>} path facts plus the UNC spelling.
325
+ */
326
+ checkPath: async ({ distro, path }) => {
327
+ // Grammar-validate the distro before any wsl.exe side effect.
328
+ const uncPath = joinWslUnc(distro, path)
329
+ const facts = await checkLinuxPath(distro, path)
330
+ return { ...facts, uncPath }
331
+ },
332
+
333
+ /**
334
+ * Resolve a location without touching the filesystem.
335
+ * @param {{ location: string, distro?: string }} params - the location to resolve.
336
+ * @returns {Promise<object>} the resolved target.
337
+ */
338
+ resolve: ({ location, distro }) => resolveLocation(location, distro),
339
+
340
+ /**
341
+ * Resolve a distribution user's home directory.
342
+ *
343
+ * The workspace dialog prefills its path with the answer, so the picker
344
+ * opens in the operator's own files rather than at the filesystem root.
345
+ * A read-only query against the distribution's user database, in the same
346
+ * discovery class as `listDir`/`checkPath`.
347
+ * @param {{ distro: string, username?: string }} params - distribution and optional user.
348
+ * @returns {Promise<object>} the resolved user and home.
349
+ */
350
+ resolveHome: ({ distro, username }) => resolveDistroHome(distro, username),
351
+
352
+ /**
353
+ * The preset a new session in a WSL workspace must be created with.
354
+ *
355
+ * The harness fixes a session's preset at creation, so the browser half has to
356
+ * name the variant in its create request; this resolves it against the live
357
+ * roster instead of letting the client guess the id.
358
+ * @param {{ base?: string }} params - base preset id; absent uses the configured default.
359
+ * @returns {Promise<{ agentPreset: string, base: string }>} the variant to request.
360
+ */
361
+ wslPresetFor: async ({ base } = {}) => {
362
+ const presets = hostCtx?.get('agentPresets')
363
+ if (presets === undefined) throw new Error('agent presets 尚不可用')
364
+ const resolved = await presets.resolve(base)
365
+ const agentPreset = resolved.id.startsWith(GENERATED_PREFIX)
366
+ ? resolved.id
367
+ : `${GENERATED_PREFIX}${resolved.id}`
368
+ const roster = await presets.list()
369
+ const variant = roster.find((preset) => preset.id === agentPreset)
370
+ if (variant === undefined) throw new Error(`WSL 预设 ${agentPreset} 尚未生成`)
371
+ if (variant.broken !== undefined) throw new Error(`WSL 预设 ${agentPreset} 无法挂载:${variant.broken}`)
372
+ return { agentPreset, base: resolved.id }
373
+ },
374
+
375
+ /**
376
+ * Run one command inside a distribution.
377
+ * @param {{ cwd?: string, distro?: string, command: string, username?: string, timeoutMs?: number }} params - execution request.
378
+ * @returns {Promise<object>} the outcome plus the exact argv used.
379
+ */
380
+ execInWsl: async ({ cwd, distro, command, username, timeoutMs }) => {
381
+ if (typeof command !== 'string') throw new Error('execInWsl: command 必须是字符串')
382
+ const target = await resolveLocation(cwd ?? '/', distro)
383
+ const result = await runWslShell({
384
+ distro: target.distro,
385
+ linuxCwd: target.linuxPath,
386
+ command,
387
+ ...(username !== undefined ? { username } : {}),
388
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
389
+ })
390
+ return { ...result, target }
391
+ },
392
+
393
+ /**
394
+ * Report the current preset generation state.
395
+ * @returns {Promise<object>} the state.
396
+ */
397
+ presetStatus: () => Promise.resolve(presetState),
398
+
399
+ /**
400
+ * Exercise one WSL session end to end through the realm's own providers.
401
+ *
402
+ * This is the acceptance path that does not need the GUI: create a session
403
+ * whose cwd is a WSL workspace and whose preset is a generated WSL variant,
404
+ * then reach the realm's `shell` / `fs` / `subprocess` with
405
+ * `agentPresets.serviceFor` — the services a realm hides from the outside —
406
+ * and run real work through each.
407
+ * @param {{ preset?: string, cwd?: string }} params - preset id and workspace cwd.
408
+ * @returns {Promise<{ steps: object[] }>} one entry per attempted step.
409
+ */
410
+ selftest: async ({ preset = 'wsl-standard', cwd }) => {
411
+ const steps = []
412
+ /**
413
+ * Record one step outcome.
414
+ * @param {string} name - step name.
415
+ * @param {object} value - observed facts.
416
+ */
417
+ const record = (name, value) => steps.push({ name, ...value })
418
+ let agent
419
+ try {
420
+ const agents = hostCtx.get('agents')
421
+ const presets = hostCtx.get('agentPresets')
422
+ if (agents === undefined || presets === undefined) throw new Error('agents / agentPresets 服务不可用')
423
+ const workspaceCwd = cwd ?? (await resolveLocation('/tmp', undefined)).uncPath
424
+ const sessionId = `wsl-selftest-${Date.now()}`
425
+ const handle = await agents.create({ sessionId, meta: { cwd: workspaceCwd, agentPreset: preset } })
426
+ agent = handle?.agent ?? handle
427
+ record('create', {
428
+ ok: true,
429
+ sessionId: String(agent?.id ?? sessionId),
430
+ handleKeys: Object.keys(handle ?? {}),
431
+ composed: presets.composedPreset(agent.ctx) ?? null,
432
+ cwd: workspaceCwd,
433
+ })
434
+
435
+ // A session created through the service does not pass the browser's
436
+ // preset intent, so the selftest selects it explicitly when the automatic
437
+ // binding has not already done so.
438
+ if (presets.composedPreset(agent.ctx) !== preset) {
439
+ try {
440
+ await presets.select(agent, preset)
441
+ record('select', { ok: true, composed: presets.composedPreset(agent.ctx) ?? null, bindings: bindingLog.length })
442
+ } catch (error) {
443
+ record('select', { ok: false, error: error instanceof Error ? error.message : String(error) })
444
+ }
445
+ }
446
+
447
+ // 0.1.7 moved serviceFor off the registry (now a standalone
448
+ // serviceForAgent in the package). The harness's own consumers use
449
+ // `agent.ctx.get(name)` — which resolves through the agent's scope chain
450
+ // into the realm — so that's the compatible form on both generations.
451
+ // 0.1.7 mounts preset services under the standing mount's own fiber —
452
+ // `agent.ctx.get` resolves the HOST services (the mount is not in the
453
+ // agent's scope chain). The harness's accessor is the exported
454
+ // serviceForAgent(ctx, agent, name); resolve it dynamically (the
455
+ // registry package lives in the host graph, not in this plugin's
456
+ // node_modules) and fall back to ctx.get on pre-0.1.7 hosts.
457
+ let realmService = null
458
+ try {
459
+ const registryModule = await import('@deepseek-ai/dsh-agent-preset-registry')
460
+ if (typeof registryModule.serviceForAgent === 'function') {
461
+ realmService = (name) => registryModule.serviceForAgent(hostCtx, agent, name)
462
+ }
463
+ } catch {
464
+ // Registry package not resolvable from this plugin — pre-0.1.7 host.
465
+ }
466
+ if (realmService === null) realmService = (name) => agent.ctx.get(name)
467
+
468
+ const shell = realmService('shell')
469
+ record('shell.service', {
470
+ found: shell !== undefined,
471
+ sandboxMode: shell?.sandboxMode ?? null,
472
+ proto: Object.getOwnPropertyNames(Object.getPrototypeOf(shell ?? {})).join(','),
473
+ executeType: typeof shell?.execute,
474
+ runType: typeof shell?.run,
475
+ })
476
+ if (shell !== undefined && typeof workspaceCwd === 'string' && workspaceCwd.startsWith('\\\\wsl.localhost\\')) {
477
+ // The live-handle shell.run block is WSL-only: a Windows session's
478
+ // bash-local cannot spawn Linux commands, and its infrastructure
479
+ // failure would abort the whole selftest.
480
+ const spec = shell.resolve({ command: 'uname -s; pwd; id -un; echo "$WSL_DISTRO_NAME"', workdir: workspaceCwd })
481
+ // 0.1.7: execute resolves with a live handle; the settled result is
482
+ // its memoized result() projection. Dual-dispatch for older hosts.
483
+ const execution = await (typeof shell.execute === 'function' ? shell.execute(spec) : (async () => { const r = await shell.run(spec); return { result: async () => r } })())
484
+ const result = await execution.result()
485
+ record('shell.run', {
486
+ exitCode: result?.exitCode,
487
+ stdoutText: result?.stdout?.text?.trim().split('\n') ?? null,
488
+ stderrText: result?.stderr?.text?.slice(0, 300) ?? null,
489
+ sandbox: result?.sandbox ?? null,
490
+ raw: JSON.stringify(result ?? null).slice(0, 400),
491
+ })
492
+ }
493
+
494
+ const fs = realmService('fs')
495
+ record('fs.service', { found: fs !== undefined })
496
+ if (fs !== undefined) {
497
+ // A mutation is called the way the tool layer calls it: `tool-fs`
498
+ // resolves a per-call policy and stamps the calling session's cwd as the
499
+ // workspace root (`tool-fs/src/sandbox.ts:88-93`). Calling `writeText`
500
+ // without that policy is a call no real consumer makes, and under a
501
+ // confining backend it is refused for having no root to be contained by.
502
+ try {
503
+ const policy = { mode: 'workspace-write', workspaceRoot: workspaceCwd }
504
+ const written = await fs.writeText(
505
+ await fs.resolve('dsh-wsl-selftest.txt', { cwd: workspaceCwd }),
506
+ 'hello from the wsl world\n',
507
+ undefined,
508
+ undefined,
509
+ policy,
510
+ )
511
+ const target = await fs.resolve('dsh-wsl-selftest.txt', { cwd: workspaceCwd })
512
+ const text = await fs.readText(target)
513
+ record('fs.roundtrip', {
514
+ operation: written.operation,
515
+ processPath: fs.processPath(target),
516
+ fileUrl: fs.fileUrl(target),
517
+ hostPath: fs.processPathFromHostPath(windowsToMntPath(workspaceCwd) ?? workspaceCwd) ?? null,
518
+ text: text.trim(),
519
+ })
520
+ } catch (error) {
521
+ record('fs.roundtrip', { error: error instanceof Error ? error.message : String(error) })
522
+ }
523
+ }
524
+
525
+ const subprocess = realmService('subprocess')
526
+ record('subprocess.service', { found: subprocess !== undefined })
527
+ if (subprocess !== undefined) {
528
+ try {
529
+ const environment = await subprocess.terminalEnvironment()
530
+ const executable = await subprocess.resolveExecutable('uname')
531
+ record('subprocess.probe', { environment, executable })
532
+ } catch (error) {
533
+ // A Windows session has no `uname` on PATH — the probe failing is
534
+ // recorded, never fatal: the selftest must keep covering the
535
+ // remaining steps.
536
+ record('subprocess.probe', { found: true, error: error instanceof Error ? error.message : String(error) })
537
+ }
538
+ }
539
+
540
+ if (shell !== undefined) {
541
+ const exec = typeof shell.execute === 'function' ? shell.execute.bind(shell) : shell.run.bind(shell)
542
+ const execution = await exec(
543
+ shell.resolve({ command: 'echo blocked > /dsh-wsl-forbidden.txt', workdir: workspaceCwd }),
544
+ ).catch((error) => ({ result: async () => ({ exitCode: null, stderr: { text: String(error?.message ?? error) }, sandbox: null }) }))
545
+ const confined = await execution.result()
546
+ record('shell.confined', {
547
+ exitCode: confined.exitCode,
548
+ stderr: confined.stderr?.text?.slice(0, 200) ?? '',
549
+ sandbox: confined.sandbox ?? null,
550
+ })
551
+ }
552
+
553
+ // The model calls tools, not providers. Driving the registry is what makes
554
+ // the acceptance cover the layer a session actually uses.
555
+ const tools = hostCtx.get('tools')
556
+ if (tools !== undefined && agent !== undefined) {
557
+ const controller = new AbortController()
558
+ /**
559
+ * Execute one tool on the session's behalf.
560
+ * @param {string} toolName - registered tool name.
561
+ * @param {object} args - parsed tool arguments.
562
+ * @returns {Promise<object>} a normalized outcome.
563
+ */
564
+ const invoke = async (toolName, args) => {
565
+ try {
566
+ const result = await tools.execute({
567
+ callId: `wsl-selftest-${toolName}-${Date.now()}`,
568
+ name: toolName,
569
+ arguments: args,
570
+ agent,
571
+ signal: controller.signal,
572
+ })
573
+ return { ok: true, result: JSON.parse(JSON.stringify(result ?? null)) }
574
+ } catch (error) {
575
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
576
+ }
577
+ }
578
+ const bashTool = await invoke('bash', { command: 'uname -s; pwd', description: 'selftest: confirm the WSL world' })
579
+ record('tools.bash', {
580
+ ok: bashTool.ok,
581
+ error: bashTool.error ?? null,
582
+ text: JSON.stringify(bashTool.result ?? null).slice(0, 400),
583
+ })
584
+ const readTool = await invoke('read', { file_path: '/tmp/dsh-wsl-selftest.txt' })
585
+ record('tools.read', {
586
+ ok: readTool.ok,
587
+ error: readTool.error ?? null,
588
+ text: JSON.stringify(readTool.result ?? null).slice(0, 400),
589
+ })
590
+ const pwshTool = await invoke('pwsh', { command: 'echo host-side', description: 'selftest: this tool must not exist in a WSL session' })
591
+ record('tools.pwsh', {
592
+ ok: pwshTool.ok,
593
+ error: pwshTool.error ?? null,
594
+ text: JSON.stringify(pwshTool.result ?? null).slice(0, 200),
595
+ })
596
+
597
+ // A tool resolves its policy from the session's logged permission state
598
+ // (`tool-bash/src/index.ts:199`), which a programmatically created
599
+ // session does not have. Recording one is what makes the tool layer's
600
+ // confinement observable here.
601
+ try {
602
+ agent.session.append('sandbox/mode', { mode: 'workspace-write' })
603
+ const confinedTool = await invoke('bash', {
604
+ command: 'echo x > /dsh-wsl-tool-forbidden.txt',
605
+ description: 'selftest: confirm the tool layer confines writes',
606
+ })
607
+ const parsed = JSON.parse(JSON.stringify(confinedTool.result ?? null))
608
+ record('tools.bashConfined', {
609
+ ok: confinedTool.ok,
610
+ error: confinedTool.error ?? null,
611
+ sandbox: parsed?.value?.sandbox ?? null,
612
+ stderr: parsed?.value?.stderr?.text?.slice(0, 200) ?? '',
613
+ })
614
+ } catch (error) {
615
+ record('tools.bashConfined', { ok: false, error: error instanceof Error ? error.message : String(error) })
616
+ }
617
+ }
618
+ return { steps }
619
+ } catch (error) {
620
+ record('error', {
621
+ message: error instanceof Error ? error.message : String(error),
622
+ stack: String(error?.stack ?? '').split('\n').slice(0, 5),
623
+ })
624
+ return { steps }
625
+ } finally {
626
+ // A selftest session is a live agent in the registry; release it.
627
+ try {
628
+ await agent?.dispose?.()
629
+ } catch {
630
+ // Disposal failure does not change what the checks observed.
631
+ }
632
+ }
633
+ },
634
+
635
+ /**
636
+ * Exercise the workspace flow the sidebar dialog performs, and clean up.
637
+ *
638
+ * The dialog's own rendering needs a browser, but its host contract does not:
639
+ * this lists a Linux directory, checks it, registers the workspace under the
640
+ * UNC spelling, and removes it again so no test workspace is left behind.
641
+ * @param {{ distro?: string, linuxPath?: string }} params - target directory.
642
+ * @returns {Promise<object>} what each step observed.
643
+ */
644
+ workspaceFlow: async ({ distro, linuxPath = '/tmp' }) => {
645
+ const registry = hostCtx.get('workspaceRegistry')
646
+ if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
647
+ const target = await resolveLocation(linuxPath, distro)
648
+ const listing = await listLinuxDir(target.distro, target.linuxPath)
649
+ const facts = await checkLinuxPath(target.distro, target.linuxPath)
650
+ const existing = await registry.resolveByPath(target.uncPath)
651
+ const workspace = existing ?? await registry.create(target.uncPath, `wsl-selftest ${target.linuxPath}`)
652
+ const created = existing === undefined
653
+ let deleted = null
654
+ if (created) deleted = await registry.delete(workspace.id)
655
+ return {
656
+ distro: target.distro,
657
+ linuxPath: target.linuxPath,
658
+ uncPath: target.uncPath,
659
+ directoryEntries: listing.entries.length,
660
+ isDirectory: facts.isDirectory,
661
+ workspaceId: String(workspace.id),
662
+ workspacePath: workspace.path,
663
+ created,
664
+ deleted,
665
+ }
666
+ },
667
+
668
+ /**
669
+ * Report recent preset-binding decisions.
670
+ * @returns {Promise<object>} the decisions, newest last.
671
+ */
672
+ bindingLog: () => Promise.resolve({ entries: bindingLog }),
673
+
674
+ /**
675
+ * Read the harness's own view of every preset's composition.
676
+ *
677
+ * This is what proves a generated preset is not merely written to disk but
678
+ * accepted, parsed and resolved by the preset registry.
679
+ * @returns {Promise<object>} the composition inventory.
680
+ */
681
+ presetInventory: async () => {
682
+ const presets = hostCtx.get('agentPresets')
683
+ if (presets === undefined) throw new Error('agentPresets 服务不可用')
684
+ return { inventory: await presets.compositionInventory() }
685
+ },
686
+
687
+ /**
688
+ * Report which presets the registry currently sees.
689
+ * @returns {Promise<object>} the roster.
690
+ */
691
+ presetRoster: async () => {
692
+ const presets = hostCtx.get('agentPresets')
693
+ if (presets === undefined) throw new Error('agentPresets 服务不可用')
694
+ const roster = await presets.list()
695
+ return { roster: roster.map((entry) => ({ id: entry.id, name: entry.name, broken: entry.broken ?? null, isDefault: entry.isDefault ?? false })) }
696
+ },
697
+
698
+ /**
699
+ * Regenerate the WSL presets from the current roster.
700
+ * @param {{ distro?: string }} params - optional distribution to pin.
701
+ * @returns {Promise<object>} the new state.
702
+ */
703
+ regeneratePresets: ({ distro }) => refreshPresets(hostCtx, distro),
704
+
705
+ /**
706
+ * Create (or find) the workspace for a WSL directory.
707
+ *
708
+ * The workspace is registered under its UNC spelling because that is the only
709
+ * form the Windows-side harness accepts as an absolute workspace path.
710
+ * @param {{ distro?: string, path: string, title?: string }} params - the WSL directory.
711
+ * @returns {Promise<object>} the workspace identity and the preset to open it with.
712
+ */
713
+ createWorkspace: async ({ distro, path, title }) => {
714
+ const target = await resolveLocation(path, distro)
715
+ const facts = await checkLinuxPath(target.distro, target.linuxPath)
716
+ if (!facts.isDirectory) throw new Error(`${target.linuxPath} 不是一个存在的目录`)
717
+ const registry = hostCtx.get('workspaceRegistry')
718
+ if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
719
+ const existing = await registry.resolveByPath(target.uncPath)
720
+ const workspace = existing ?? await registry.create(target.uncPath, title ?? target.linuxPath)
721
+ // The preset a client should open this workspace with, resolved against the
722
+ // live roster exactly like `wslPresetFor` — not a hardcoded guess. The
723
+ // workspace itself does not depend on it, so an unavailable registry
724
+ // degrades to the default spelling instead of failing the creation.
725
+ let presetId = 'wsl-standard'
726
+ try {
727
+ const presets = hostCtx.get('agentPresets')
728
+ if (presets !== undefined) {
729
+ const resolved = await presets.resolve()
730
+ const candidate = resolved.id.startsWith(GENERATED_PREFIX)
731
+ ? resolved.id
732
+ : `${GENERATED_PREFIX}${resolved.id}`
733
+ const roster = await presets.list()
734
+ if (roster.some((preset) => preset.id === candidate && preset.broken === undefined)) presetId = candidate
735
+ }
736
+ } catch {
737
+ // Reported as the default spelling; the browser half resolves again at
738
+ // session-creation time, which is the seam that matters.
739
+ }
740
+ return {
741
+ workspaceId: workspace.id,
742
+ path: workspace.path,
743
+ title: workspace.title,
744
+ created: existing === undefined,
745
+ presetId,
746
+ linuxPath: target.linuxPath,
747
+ distro: target.distro,
748
+ }
749
+ },
750
+
751
+ /**
752
+ * List the registered WSL variant presets (0.1.7 registry roster).
753
+ * @returns {Promise<object>} the variant ids and display names.
754
+ */
755
+ listPresetDirectories: async () => {
756
+ // 0.1.6 read generated directories off disk; the registry is the source of
757
+ // truth now. The method name is kept for the developer surface.
758
+ const presets = hostCtx?.get('agentPresets')
759
+ if (presets === undefined) return { root: null, names: [] }
760
+ const roster = await presets.list()
761
+ const names = roster
762
+ .filter((preset) => typeof preset.id === 'string' && preset.id.startsWith(GENERATED_PREFIX))
763
+ .map((preset) => preset.id)
764
+ return { root: null, names }
765
+ },
766
+
767
+ /**
768
+ * Read one registered preset's declared child-plugin list, for inspecting
769
+ * the transform in place.
770
+ * @param {{ id: string }} params - preset id.
771
+ * @returns {Promise<object>} the preset document.
772
+ */
773
+ readPreset: async ({ id }) => {
774
+ if (typeof id !== 'string' || !/^[A-Za-z0-9._-]+$/.test(id)) {
775
+ throw new Error(`readPreset: 非法的预设 id ${JSON.stringify(id)}`)
776
+ }
777
+ const presets = hostCtx?.get('agentPresets')
778
+ if (presets === undefined) throw new Error('readPreset: agentPresets 服务不可用')
779
+ // 0.1.7: readDocument returns the declaration re-dumped as YAML content.
780
+ const doc = await presets.readDocument(id)
781
+ return { id: doc.agentPreset, content: doc.content }
782
+ },
783
+ }
784
+
785
+ /**
786
+ * Read one JSON request body, bounded.
787
+ * @param {import('node:http').IncomingMessage} req - the request to drain.
788
+ * @returns {Promise<{ text: string | null, byteLength: number }>} the body, or null past the ceiling.
789
+ */
790
+ async function readJsonBody(req) {
791
+ const chunks = []
792
+ let size = 0
793
+ for await (const chunk of req) {
794
+ size += chunk.byteLength
795
+ if (size > MAX_BODY_BYTES) {
796
+ // Drain the remainder so the refusal is a readable response, not a socket cut.
797
+ req.resume()
798
+ return { text: null, byteLength: size }
799
+ }
800
+ chunks.push(chunk)
801
+ }
802
+ return { text: Buffer.concat(chunks, size).toString('utf8'), byteLength: size }
803
+ }
804
+
805
+ /**
806
+ * Answer one request with a JSON envelope and an explicit status.
807
+ * @param {import('node:http').ServerResponse} res - the response to complete.
808
+ * @param {number} status - the HTTP status.
809
+ * @param {{ ok: boolean }} payload - the envelope, success or failure.
810
+ */
811
+ function sendJson(res, status, payload) {
812
+ const body = JSON.stringify(payload)
813
+ res.writeHead(status, {
814
+ 'content-type': 'application/json; charset=utf-8',
815
+ 'content-length': Buffer.byteLength(body),
816
+ 'cache-control': 'no-store',
817
+ })
818
+ res.end(body)
819
+ }
820
+
821
+ /**
822
+ * Answer with one admission rejection.
823
+ * @param {import('node:http').ServerResponse} res - the response to complete.
824
+ * @param {import('./http-admission.js').Rejection} rejection - the refusal.
825
+ */
826
+ function sendRejection(res, rejection) {
827
+ sendJson(res, rejection.status, { ok: false, code: rejection.code, error: rejection.message })
828
+ }
829
+
830
+ /** Trust surface consumed here; the browser-side connection package owns the full type. */
831
+ function connectionOf(ctx) {
832
+ return Reflect.get(ctx, 'connection')
833
+ }
834
+
835
+ /**
836
+ * This installation's development token, minted on first use.
837
+ *
838
+ * The file is owner-only and lives outside the repository. A web page cannot
839
+ * read it, which is what keeps the acceptance surface unreachable from a
840
+ * cross-site request even though the route itself is on loopback.
841
+ * @returns {Promise<string>} the token.
842
+ */
843
+ async function devToken() {
844
+ const path = join(dshHome(), 'wsl-desktop-dev-token')
845
+ try {
846
+ const existing = (await readFile(path, 'utf8')).trim()
847
+ if (existing.length > 0) return existing
848
+ // An empty file is unusual — treat it like absent and re-mint below.
849
+ } catch (error) {
850
+ // ENOENT = first use. Any other read failure (ACL, AV lock, disk) must
851
+ // fail loud rather than silently rotate the credential and revoke every
852
+ // scripted caller.
853
+ if (error?.code !== 'ENOENT') throw error
854
+ }
855
+ const minted = randomBytes(32).toString('hex')
856
+ await mkdir(dirname(path), { recursive: true })
857
+ await writeFile(path, `${minted}\n`, { mode: 0o600 })
858
+ return minted
859
+ }
860
+
861
+ /**
862
+ * Whether one request may use the acceptance surface.
863
+ * @param {import('node:http').IncomingMessage} req - the request.
864
+ * @param {{ developerTools: boolean }} config - resolved plugin config.
865
+ * @returns {Promise<boolean>} true for a caller presenting this installation's token.
866
+ */
867
+ async function isDeveloperCaller(req, config) {
868
+ if (config.developerTools !== true) return false
869
+ return tokenMatches(req.headers[DEV_TOKEN_HEADER], await devToken(), timingSafeEqual)
870
+ }
871
+
872
+ /**
873
+ * Register the WSL route and generate the WSL presets.
874
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
875
+ * @param {{ routePath: string, developerTools: boolean }} config - resolved plugin config.
876
+ */
877
+ export function apply(ctx, config) {
878
+ hostCtx = ctx
879
+ // Bound concurrent route methods: each discovery call spawns wsl.exe, and a
880
+ // misbehaving page could fan out without limit.
881
+ let routeInFlight = 0
882
+ // The WSL providers start `wsl.exe`, an ordinary Windows process, through the
883
+ // host's own subprocess provider. A realm that isolates `subprocess` shadows
884
+ // `ctx.subprocess`, so the root provider is captured here for them to reuse.
885
+ ctx.inject(['subprocess'], (scope) => {
886
+ setLocalSubprocess(scope.subprocess)
887
+ })
888
+ ctx.effect(() => ctx.webServer.register({
889
+ kind: 'exact',
890
+ path: config.routePath,
891
+ handler: async (req, res) => {
892
+ // The fence first, exactly as every shipped host route does it: without
893
+ // this, a cross-site `text/plain` POST is a CORS-simple request with no
894
+ // preflight, and this route can run commands.
895
+ const rejection = connectionOf(ctx).requestRejection(req)
896
+ const developer = await isDeveloperCaller(req, config)
897
+ if (rejection !== undefined && !developer) {
898
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
899
+ res.end()
900
+ return
901
+ }
902
+ const early = preflight({ httpMethod: req.method, contentType: req.headers['content-type'] })
903
+ if (early.ok !== true) {
904
+ sendRejection(res, early)
905
+ return
906
+ }
907
+ let body
908
+ try {
909
+ body = await readJsonBody(req)
910
+ } catch (error) {
911
+ sendJson(res, 400, { ok: false, code: 'bad-request', error: `请求体读取失败:${String(error)}` })
912
+ return
913
+ }
914
+ const sized = bodyAdmission({ byteLength: body.text === null ? MAX_BODY_BYTES + 1 : body.byteLength })
915
+ if (sized.ok !== true) {
916
+ sendRejection(res, sized)
917
+ return
918
+ }
919
+ let envelope
920
+ try {
921
+ envelope = JSON.parse(body.text || '{}')
922
+ } catch (error) {
923
+ sendJson(res, 400, { ok: false, code: 'bad-request', error: `请求体不是合法 JSON:${String(error)}` })
924
+ return
925
+ }
926
+ const method = envelope === null || typeof envelope !== 'object' ? undefined : envelope.method
927
+ const named = methodAdmission({ method })
928
+ if (named.ok !== true) {
929
+ sendRejection(res, named)
930
+ return
931
+ }
932
+ const run = Object.prototype.hasOwnProperty.call(METHODS, method) ? METHODS[method] : undefined
933
+ const admitted = dispatchAdmission({
934
+ method,
935
+ developer,
936
+ known: run !== undefined,
937
+ browserReachable: BROWSER_METHODS.has(method),
938
+ })
939
+ if (admitted.ok !== true) {
940
+ sendRejection(res, admitted)
941
+ return
942
+ }
943
+ if (routeInFlight >= 8) {
944
+ sendJson(res, 503, { ok: false, code: 'too-busy' })
945
+ return
946
+ }
947
+ routeInFlight += 1
948
+ try {
949
+ sendJson(res, 200, { ok: true, value: await run(envelope.params ?? {}) })
950
+ } catch (error) {
951
+ // A rejected method is a request-level outcome, not a transport fault.
952
+ // For browser callers the error message may embed wsl.exe stderr or
953
+ // resolved host paths (same-principal for the page, but codes-only
954
+ // shrinks the surface without losing diagnostic value for the dev).
955
+ if (developer) {
956
+ sendJson(res, 400, {
957
+ ok: false,
958
+ code: 'method-failed',
959
+ error: error instanceof Error ? error.message : String(error),
960
+ })
961
+ } else {
962
+ sendJson(res, 400, { ok: false, code: 'method-failed' })
963
+ }
964
+ } finally {
965
+ routeInFlight -= 1
966
+ }
967
+ },
968
+ }), `wsl-desktop: POST ${config.routePath}`)
969
+ // Preset generation is best-effort at boot: the route reports the outcome
970
+ // instead of failing the whole composition when the roster is unavailable.
971
+ // `inject` waits for the registry rather than racing boot order.
972
+ ctx.inject(['agentPresets'], (scope) => {
973
+ // Per-generation lifecycle token: teardown flips `disposed` synchronously
974
+ // as its FIRST statement, before draining — a materialization parked at an
975
+ // await re-checks the flag around every register() and hands any
976
+ // just-registered variant straight back to its disposer, so a disable
977
+ // landing mid-registration cannot orphan a wsl-* preset in the registry.
978
+ const generation = { disposed: false }
979
+ currentGeneration = generation
980
+ scope.effect(() => () => {
981
+ generation.disposed = true
982
+ for (const [, dispose] of variantDisposers) void dispose()
983
+ variantDisposers.clear()
984
+ }, 'wsl-desktop: variant preset disposal')
985
+ void defaultDistro().then(
986
+ (distro) => refreshPresets(scope, distro, generation),
987
+ () => refreshPresets(scope, undefined, generation),
988
+ )
989
+ })
990
+
991
+ // The browser half names the WSL preset in its session create request — the
992
+ // only race-free seam, because the harness composes the preset at creation.
993
+ // This listener is the LAST RESORT for a WSL-workspace session created
994
+ // without a preset (some other entry point): it tries a post-hoc select,
995
+ // which is refused for any session that has already taken a turn, and both
996
+ // outcomes land in `bindingLog` as an anomaly signal.
997
+ //
998
+ // ONE listener, not two. `agent/created` is a scoped event, but a listener
999
+ // registered without a scope is admitted globally, so registering both paths
1000
+ // made every session issue two concurrent `select` calls and append two
1001
+ // `agent-preset/selected` events for one creation. `api-session/added` carries
1002
+ // no scope, always arrives, and its summary carries the cwd and blankness the
1003
+ // decision needs, so the agent is looked up from the registry.
1004
+ ctx.on('api-session/added', (summary) => {
1005
+ void bindWslSession(ctx, summary)
1006
+ })
1007
+ }
1008
+
1009
+ /**
1010
+ * Append one preset-binding decision, trimming to the inspection limit.
1011
+ * @param {object} entry - the decision to record.
1012
+ */
1013
+ function recordBinding(entry) {
1014
+ bindingLog.push(entry)
1015
+ if (bindingLog.length > BINDING_LOG_LIMIT) bindingLog.splice(0, bindingLog.length - BINDING_LOG_LIMIT)
1016
+ }
1017
+
1018
+ /**
1019
+ * Bind one newly visible session to the WSL variant of its preset.
1020
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
1021
+ * @param {{ sessionId?: unknown, cwd?: unknown }} summary - the session summary.
1022
+ * @returns {Promise<void>} settlement.
1023
+ */
1024
+ async function bindWslSession(ctx, summary) {
1025
+ const cwd = typeof summary?.cwd === 'string' ? summary.cwd : ''
1026
+ if (parseWslUnc(cwd) === null) return
1027
+ const agents = ctx.get('agents')
1028
+ if (agents === undefined) return
1029
+ const agent = agents.get(summary?.sessionId)
1030
+ if (agent === undefined) {
1031
+ recordBinding({ sessionId: String(summary?.sessionId), cwd, ok: false, error: 'agent 尚未注册' })
1032
+ return
1033
+ }
1034
+ await bindWslPreset(ctx, agent, cwd, String(summary?.sessionId))
1035
+ }
1036
+
1037
+ /**
1038
+ * Select the WSL variant of the agent's current preset.
1039
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
1040
+ * @param {object} agent - the live agent.
1041
+ * @param {string} cwd - the session working directory.
1042
+ * @param {string} sessionId - the session identity, for the log.
1043
+ * @returns {Promise<void>} settlement.
1044
+ */
1045
+ async function bindWslPreset(ctx, agent, cwd, sessionId) {
1046
+ const presets = ctx.get('agentPresets')
1047
+ if (presets === undefined) return
1048
+ const current = presets.composedPreset(agent.ctx)
1049
+ if (typeof current === 'string' && current.startsWith(GENERATED_PREFIX)) return
1050
+ const from = typeof current === 'string' && current.length > 0 ? current : 'standard'
1051
+ const wanted = `${GENERATED_PREFIX}${from}`
1052
+ try {
1053
+ const chosen = await presets.select(agent, wanted)
1054
+ recordBinding({ sessionId, cwd, from, to: chosen, ok: true })
1055
+ } catch (error) {
1056
+ const reason = error instanceof Error ? error.message : String(error)
1057
+ recordBinding({ sessionId, cwd, from, to: wanted, ok: false, error: reason })
1058
+ // This is the last-resort path for a session this plugin did not create
1059
+ // (the browser half names the preset in its create request, which is the
1060
+ // only race-free seam). A refusal here means the session is running in the
1061
+ // host world, so it must not stay invisible in an in-memory array.
1062
+ ctx.logger?.warn?.(
1063
+ `wsl-desktop: 会话 ${sessionId} 未能切到 ${wanted}(${reason});`
1064
+ + '它运行在宿主执行世界里。请在 W 对话框里新建会话,或在预设选择器里手动切换。',
1065
+ )
1066
+ }
1067
+ }