dsh-wsl-desktop 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -13,19 +13,25 @@
13
13
  * @module dsh-wsl-desktop
14
14
  */
15
15
 
16
- import { randomBytes, timingSafeEqual } from 'node:crypto'
17
- import { existsSync, watch as nativeFsWatch } from 'node:fs'
18
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
19
- import { homedir } from 'node:os'
16
+ import { watch as nativeFsWatch } from 'node:fs'
17
+ import { mkdir, readFile } from 'node:fs/promises'
20
18
  import { dirname, join } from 'node:path'
21
19
  import { fileURLToPath, pathToFileURL } from 'node:url'
22
20
  import z from '@deepseek-ai/schemastery'
23
21
  import {
24
22
  MAX_BODY_BYTES, DEV_TOKEN_HEADER, bodyAdmission, dispatchAdmission, fenceRejection, methodAdmission, preflight, tokenMatches,
25
23
  } from './http-admission.js'
24
+ import {
25
+ connectionOf, devToken, isDeveloperCaller, readJsonBody, sendJson, sendRejection,
26
+ } from './host/http.js'
27
+ import {
28
+ GENERATED_PREFIX, basePluginsOf, runShellSpec, sourceDirFor, variantIdFor,
29
+ } from './host/presets.js'
30
+ import { bindingTape, hostSession, presetRun } from './host/state.js'
26
31
  import { buildVariantPlugins } from './wsl/preset.js'
27
32
  import { setLocalSubprocess } from './wsl/host-refs.js'
28
- import { checkLinuxPath, defaultDistro, defaultWorkspaceUnc, listDistros, listLinuxDir, resolveDistroHome, runWslShell, wslMajorVersion } from './wsl/world.js'
33
+ import { checkLinuxPath, defaultWorkspaceUnc, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
34
+ import { defaultDistro, listDistros, wslMajorVersion } from './wsl/distros.js'
29
35
  import { joinWslUnc, parseWslUnc, shellQuote, windowsToMntPath } from './wsl/paths.js'
30
36
  import { WslRouteError, browserFailure, routeErrorFor } from './route-errors.js'
31
37
  import { CHECK_IDS, CHECK_LABELS, CHECK_REMEDIES, DOCS, ERROR_CODES, FEATURE_IDS, FEATURE_LABELS } from './protocol.js'
@@ -87,23 +93,6 @@ const HERE = dirname(fileURLToPath(import.meta.url))
87
93
  */
88
94
  const SELF_MODULE = fileURLToPath(import.meta.url)
89
95
 
90
- /** Prefix of every preset this plugin owns; its own outputs are never sources. */
91
- const GENERATED_PREFIX = 'wsl-'
92
-
93
- /**
94
- * The generated variant id for a resolved base preset.
95
- *
96
- * A preset that is already a variant keeps its id; anything else gets the prefix. The
97
- * selftest and createWorkspace both derived it this way — the second site's own comment
98
- * admitted it resolved exactly like the first — so a change to the prefix rule had two
99
- * homes.
100
- * @param {{ id: string }} preset - the resolved base preset.
101
- * @returns {string} the variant id.
102
- */
103
- function variantIdFor(preset) {
104
- return preset.id.startsWith(GENERATED_PREFIX) ? preset.id : `${GENERATED_PREFIX}${preset.id}`
105
- }
106
-
107
96
  /**
108
97
  * Module specifiers of the WSL providers, as the preset rows must name them.
109
98
  * The 0.1.7 loader imports row names via `new URL(name, baseUrl)` or a bare
@@ -132,30 +121,6 @@ const SANDBOX_MODULE = pathToFileURL(join(HERE, 'wsl', 'sandbox.js')).href
132
121
  */
133
122
  const SEARCH_MODULE = pathToFileURL(join(HERE, 'wsl', 'search-tool.js')).href
134
123
 
135
- /** Last preset materialization outcome, reported through the route. */
136
- let presetState = { status: 'pending' }
137
-
138
- /** Recent preset-binding decisions, reported through the route. */
139
- const bindingLog = []
140
-
141
- /** Most binding decisions kept for inspection. */
142
- const BINDING_LOG_LIMIT = 50
143
-
144
- /** The plugin context, captured so route methods can reach host services. */
145
- let hostCtx
146
-
147
- /**
148
- * The configured `wsl.exe` path, captured where the route methods can read it.
149
- *
150
- * The methods table is module-level (the route dispatcher calls into it without
151
- * carrying the plugin's config), so the configured path is held beside the
152
- * captured context rather than threaded through every signature.
153
- */
154
- let routeWslPath = 'wsl.exe'
155
-
156
- /** Whether the preset registry has been reached; `bootstrap` waits briefly for it. */
157
- let hostReady = false
158
-
159
124
  /** The plugin version, read from the shipped package.json once. */
160
125
  let pluginVersionValue = null
161
126
 
@@ -184,21 +149,21 @@ async function pluginVersion() {
184
149
  * @returns {Promise<object>} the probe, or a pending marker.
185
150
  */
186
151
  async function probeHostWhenReady() {
187
- if (hostCtx === undefined) {
152
+ if (hostSession.ctx === undefined) {
188
153
  return { status: 'pending', features: {}, missing: [], checkedAt: new Date().toISOString() }
189
154
  }
190
155
  const deadline = Date.now() + 5_000
191
- while (!hostReady && Date.now() < deadline) {
156
+ while (!hostSession.ready && Date.now() < deadline) {
192
157
  await new Promise((resolve) => setTimeout(resolve, 100))
193
158
  }
194
- if (!hostReady) {
159
+ if (!hostSession.ready) {
195
160
  // The registry never appeared. Probing the context anyway reports the missing
196
161
  // feature as degraded; returning `pending` here would leave the picker
197
162
  // proceeding against a world it cannot name a preset in.
198
- if (hostCtx.get('agentPresets') === undefined) return probeHost(hostCtx)
163
+ if (hostSession.ctx.get('agentPresets') === undefined) return probeHost(hostSession.ctx)
199
164
  return { status: 'pending', features: {}, missing: [], checkedAt: new Date().toISOString() }
200
165
  }
201
- return probeHost(hostCtx)
166
+ return probeHost(hostSession.ctx)
202
167
  }
203
168
 
204
169
  /**
@@ -209,14 +174,6 @@ const DISCOVERY_METHODS = new Set([
209
174
  'listDistros', 'defaultDistro', 'listDir', 'checkPath', 'resolve', 'resolveHome', 'wslPresetFor',
210
175
  ])
211
176
 
212
- /**
213
- * The harness home that holds the user preset root.
214
- * @returns {string} the resolved `$DSH_HOME`.
215
- */
216
- function dshHome() {
217
- return process.env.DSH_HOME ?? join(homedir(), '.dsh')
218
- }
219
-
220
177
  /**
221
178
  * Resolve a location the caller supplied into one distribution plus Linux path.
222
179
  * @param {unknown} location - UNC path, absolute Linux path, or Windows drive path.
@@ -235,53 +192,6 @@ async function resolveLocation(location, distroHint) {
235
192
  return { distro, linuxPath, uncPath: joinWslUnc(distro, linuxPath) }
236
193
  }
237
194
 
238
- /**
239
- * Locate the directory a preset was discovered under.
240
- *
241
- * A preset may name a row by a path relative to its own directory, so the
242
- * variant needs that base to rewrite the name to something that still resolves
243
- * from where the variant lives.
244
- *
245
- * Both spellings are historical: the package is `@deepseek-ai/dsh-agent-preset`
246
- * (singular — the plural name never existed, so this resolve always threw), and
247
- * since the presets moved into profile YAML declarations the package ships no
248
- * `presets/<id>/agent.cordis.yml` tree at all. Neither branch is expected to
249
- * hit on a current desktop; a base preset that DOES use relative row names
250
- * therefore has no resolvable source directory, and `buildVariantPlugins`
251
- * leaves the name as it found it. This stays a lookup rather than a guess so
252
- * the failure mode is unchanged if a deployment ever ships that tree again.
253
- * @param {string} id - preset id.
254
- * @returns {string | undefined} the directory, or undefined when it cannot be found.
255
- */
256
- function sourceDirFor(id) {
257
- const local = join(dshHome(), '.agent-presets', id)
258
- if (existsSync(join(local, 'agent.cordis.yml'))) return local
259
- try {
260
- const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-agent-preset/package.json'))
261
- const shipped = join(dirname(manifest), 'presets', id)
262
- if (existsSync(join(shipped, 'agent.cordis.yml'))) return shipped
263
- } catch {
264
- // A deployment without the preset package leaves relative names as they are.
265
- }
266
- return undefined
267
- }
268
-
269
- /**
270
- * Read a base preset's declared plugin rows as OBJECTS.
271
- *
272
- * 0.1.7 keeps each declaration's `PresetDefinition` — with `plugins` as entry
273
- * objects — in a registry map that is TS-private but reachable in-process
274
- * (ctx.get returns the service instance). No YAML parser is needed on this
275
- * path; the public readDocument only exposes a re-dumped text form.
276
- * @param {object} presets - the agentPresets service.
277
- * @param {string} id - base preset id.
278
- * @returns {readonly object[] | null} the declared rows, or null when unreachable.
279
- */
280
- function basePluginsOf(presets, id) {
281
- const plugins = presets.definitions?.get?.(id)?.config?.plugins
282
- return Array.isArray(plugins) ? plugins : null
283
- }
284
-
285
195
  /**
286
196
  * Derive and register the WSL variant presets (0.1.7-native).
287
197
  *
@@ -303,24 +213,6 @@ function basePluginsOf(presets, id) {
303
213
  * refuses duplicates).
304
214
  * @returns {Promise<object>} the registration outcome for the route.
305
215
  */
306
- /**
307
- * Run a shell spec through whichever seam the host exposes, and always hand back a
308
- * handle whose `result()` is awaitable.
309
- *
310
- * 0.1.7 hosts expose `execute`, which resolves with a live handle; older ones have
311
- * only `run`, whose settled result carries no `result()` projection. Both selftest
312
- * steps used to spell that dual dispatch themselves, and the second one called
313
- * `.result()` on `run`'s settled result — a TypeError on exactly the hosts that
314
- * branch exists to support. One owner, so the two steps cannot drift again.
315
- * @param {object} shell - the realm's shell service.
316
- * @param {object} spec - the resolved spec to run.
317
- * @returns {Promise<{ result: () => Promise<object> }>} a result()-able handle.
318
- */
319
- function runShellSpec(shell, spec) {
320
- if (typeof shell.execute === 'function') return shell.execute(spec)
321
- return shell.run(spec).then((settled) => ({ result: async () => settled }))
322
- }
323
-
324
216
  async function materializePresets(ctx, distro, generation, previous) {
325
217
  const presets = ctx.get('agentPresets')
326
218
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
@@ -415,12 +307,6 @@ async function materializePresets(ctx, distro, generation, previous) {
415
307
  return { written, skipped, hostCompat }
416
308
  }
417
309
 
418
- /** Serialises refreshPresets so boot-time and developer-token calls cannot interleave. */
419
- let refreshMutex = Promise.resolve()
420
-
421
- /** The live registration generation; teardown flips its disposed flag. */
422
- let currentGeneration = null
423
-
424
310
  /**
425
311
  * Refresh the presets, recording the outcome for the route.
426
312
  * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
@@ -429,7 +315,7 @@ let currentGeneration = null
429
315
  * generation's lifecycle token (see materializePresets).
430
316
  * @returns {Promise<object>} the new preset state.
431
317
  */
432
- async function refreshPresets(ctx, distro, generation = currentGeneration) {
318
+ async function refreshPresets(ctx, distro, generation = presetRun.generation) {
433
319
  const run = async () => {
434
320
  // A caller may omit the generation (developer-token regenerate before the
435
321
  // inject ran): mint a transient one so every disposer has an owner.
@@ -437,19 +323,19 @@ async function refreshPresets(ctx, distro, generation = currentGeneration) {
437
323
  // Withdrawal targets the PREVIOUS generation's disposers; a self-refresh
438
324
  // (developer-token regenerate) withdraws its own — the exact behaviour of
439
325
  // the historical shared map, minus the cross-generation teardown theft.
440
- const previous = currentGeneration === gen ? gen : currentGeneration
441
- currentGeneration = gen
442
- presetState = { status: 'running' }
326
+ const previous = presetRun.generation === gen ? gen : presetRun.generation
327
+ presetRun.generation = gen
328
+ presetRun.state = { status: 'running' }
443
329
  try {
444
330
  const result = await materializePresets(ctx, distro, gen, previous)
445
- presetState = { status: 'ready', ...result }
331
+ presetRun.state = { status: 'ready', ...result }
446
332
  } catch (error) {
447
- presetState = { status: 'failed', error: error instanceof Error ? error.message : String(error) }
333
+ presetRun.state = { status: 'failed', error: error instanceof Error ? error.message : String(error) }
448
334
  }
449
- return presetState
335
+ return presetRun.state
450
336
  }
451
- const result = refreshMutex.then(run, run)
452
- refreshMutex = result.then(() => {}, () => {})
337
+ const result = presetRun.mutex.then(run, run)
338
+ presetRun.mutex = result.then(() => {}, () => {})
453
339
  return result
454
340
  }
455
341
 
@@ -510,8 +396,8 @@ const METHODS = {
510
396
  return healthFor({
511
397
  distro,
512
398
  username,
513
- wslPath: routeWslPath,
514
- wslVersionProbe: ({ distro: name }) => wslMajorVersion(name, { wslPath: routeWslPath }),
399
+ wslPath: hostSession.wslPath,
400
+ wslVersionProbe: ({ distro: name }) => wslMajorVersion(name, { wslPath: hostSession.wslPath }),
515
401
  })
516
402
  },
517
403
 
@@ -519,7 +405,7 @@ const METHODS = {
519
405
  * List the installed distributions.
520
406
  * @returns {Promise<string[]>} distribution names.
521
407
  */
522
- listDistros: () => listDistros({ wslPath: routeWslPath }),
408
+ listDistros: () => listDistros({ wslPath: hostSession.wslPath }),
523
409
 
524
410
  /**
525
411
  * Report the default distribution.
@@ -536,7 +422,7 @@ const METHODS = {
536
422
  assertDistro(distro)
537
423
  assertLinuxPath(path ?? '/')
538
424
  try {
539
- return await listLinuxDir(distro, path ?? '/', { wslPath: routeWslPath })
425
+ return await listLinuxDir(distro, path ?? '/', { wslPath: hostSession.wslPath })
540
426
  } catch (error) {
541
427
  throw routeErrorFor(error, { distro, path })
542
428
  }
@@ -554,7 +440,7 @@ const METHODS = {
554
440
  const uncPath = joinWslUnc(distro, path)
555
441
  let facts
556
442
  try {
557
- facts = await checkLinuxPath(distro, path, { wslPath: routeWslPath })
443
+ facts = await checkLinuxPath(distro, path, { wslPath: hostSession.wslPath })
558
444
  } catch (error) {
559
445
  throw routeErrorFor(error, { distro, path })
560
446
  }
@@ -601,7 +487,7 @@ const METHODS = {
601
487
  assertDistro(distro)
602
488
  assertUser(username)
603
489
  try {
604
- return await resolveDistroHome(distro, username, { wslPath: routeWslPath })
490
+ return await resolveDistroHome(distro, username, { wslPath: hostSession.wslPath })
605
491
  } catch (error) {
606
492
  throw routeErrorFor(error, { distro, user: username })
607
493
  }
@@ -617,7 +503,7 @@ const METHODS = {
617
503
  * @returns {Promise<{ agentPreset: string, base: string }>} the variant to request.
618
504
  */
619
505
  wslPresetFor: async ({ base } = {}) => {
620
- const presets = hostCtx?.get('agentPresets')
506
+ const presets = hostSession.ctx?.get('agentPresets')
621
507
  if (presets === undefined) throw new WslRouteError('host-capability-missing', { feature: 'preset-generation' })
622
508
  let resolved
623
509
  try {
@@ -669,7 +555,7 @@ const METHODS = {
669
555
  ...(username !== undefined ? { username } : {}),
670
556
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
671
557
  ...(raw === true ? { raw: true } : {}),
672
- wslPath: routeWslPath,
558
+ wslPath: hostSession.wslPath,
673
559
  })
674
560
  return { ...result, target }
675
561
  },
@@ -679,7 +565,7 @@ const METHODS = {
679
565
  * @returns {Promise<object>} the state, plus `deployed` — the path the running host loaded.
680
566
  */
681
567
  presetStatus: () => Promise.resolve({
682
- ...presetState,
568
+ ...presetRun.state,
683
569
  deployed: { modulePath: SELF_MODULE },
684
570
  }),
685
571
 
@@ -708,8 +594,8 @@ const METHODS = {
708
594
  let handle
709
595
  let agent
710
596
  try {
711
- const agents = hostCtx.get('agents')
712
- const presets = hostCtx.get('agentPresets')
597
+ const agents = hostSession.ctx.get('agents')
598
+ const presets = hostSession.ctx.get('agentPresets')
713
599
  if (agents === undefined || presets === undefined) throw new Error('agents / agentPresets 服务不可用')
714
600
  // NOT /tmp: the fence refuses a workspace root under /tmp by design (the
715
601
  // confinement mounts a private tmpfs over /tmp AFTER binding the workspace,
@@ -721,7 +607,7 @@ const METHODS = {
721
607
  // throws before any probe — and then handed its {user, home} result to a
722
608
  // function that requires a path string. Nothing reddened: every acceptance
723
609
  // path passes cwd. The default is now exercised by verify-world/check-2.
724
- const workspaceCwd = cwd ?? (await defaultWorkspaceUnc({ wslPath: routeWslPath })).uncPath
610
+ const workspaceCwd = cwd ?? (await defaultWorkspaceUnc({ wslPath: hostSession.wslPath })).uncPath
725
611
  const sessionId = `wsl-selftest-${Date.now()}`
726
612
  handle = await agents.create({ sessionId, meta: { cwd: workspaceCwd, agentPreset: preset } })
727
613
  agent = handle?.agent ?? handle
@@ -739,7 +625,7 @@ const METHODS = {
739
625
  if (presets.composedPreset(agent.ctx) !== preset) {
740
626
  try {
741
627
  await presets.select(agent, preset)
742
- record('select', { ok: true, composed: presets.composedPreset(agent.ctx) ?? null, bindings: bindingLog.length })
628
+ record('select', { ok: true, composed: presets.composedPreset(agent.ctx) ?? null, bindings: bindingTape.log.length })
743
629
  } catch (error) {
744
630
  record('select', { ok: false, error: error instanceof Error ? error.message : String(error) })
745
631
  }
@@ -958,7 +844,7 @@ const METHODS = {
958
844
 
959
845
  // The model calls tools, not providers. Driving the registry is what makes
960
846
  // the acceptance cover the layer a session actually uses.
961
- const tools = hostCtx.get('tools')
847
+ const tools = hostSession.ctx.get('tools')
962
848
  if (tools !== undefined && agent !== undefined) {
963
849
  const controller = new AbortController()
964
850
  /**
@@ -969,14 +855,38 @@ const METHODS = {
969
855
  */
970
856
  const invoke = async (toolName, args) => {
971
857
  try {
972
- const result = await tools.execute({
973
- callId: `wsl-selftest-${toolName}-${Date.now()}`,
974
- name: toolName,
975
- arguments: args,
858
+ // Host 0.2.1-rc.3 changed `tools.execute` from ONE call to a BATCH: it now
859
+ // takes `{ calls: ToolCallBlock[], signal, agent? }` and answers
860
+ // `{ results, additionalContexts, concluded }`. The previous single-call
861
+ // object is rejected with `ToolBatchExecutionError` ("Tool batch execution
862
+ // failed"), which every tool row below reported as a null result.
863
+ //
864
+ // `ToolCallBlock.arguments` is the RAW JSON STRING the model produces, not an
865
+ // object — the block is the wire shape, not a convenience wrapper.
866
+ const batch = await tools.execute({
867
+ calls: [{
868
+ type: 'tool-call',
869
+ id: `wsl-selftest-${toolName}-${Date.now()}`,
870
+ name: toolName,
871
+ arguments: JSON.stringify(args ?? {}),
872
+ }],
976
873
  agent,
977
874
  signal: controller.signal,
978
875
  })
979
- return { ok: true, result: JSON.parse(JSON.stringify(result ?? null)) }
876
+ const outcome = batch?.results?.[0]
877
+ if (outcome === undefined) return { ok: false, error: 'the batch returned no result for this call' }
878
+ // `ToolCallResult` is `{ callId, result: ToolExecutionResult }`, and the
879
+ // execution result carries `value` (often a serialized JSON string). The
880
+ // rows below read `.text`, so the text is what this normalises: a string
881
+ // value is used as-is, anything else is serialized.
882
+ const execution = outcome.result ?? {}
883
+ const isError = execution.isError === true || execution.error !== undefined
884
+ const text = typeof execution.value === 'string'
885
+ ? execution.value
886
+ : JSON.stringify(execution.value ?? execution ?? null)
887
+ return isError
888
+ ? { ok: false, error: String(execution.error?.message ?? execution.error ?? 'the tool reported an error'), text }
889
+ : { ok: true, result: JSON.parse(JSON.stringify(execution ?? null)), text }
980
890
  } catch (error) {
981
891
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
982
892
  }
@@ -985,7 +895,7 @@ const METHODS = {
985
895
  record('tools.bash', {
986
896
  ok: bashTool.ok,
987
897
  error: bashTool.error ?? null,
988
- text: JSON.stringify(bashTool.result ?? null).slice(0, 400),
898
+ text: (bashTool.text ?? JSON.stringify(bashTool.result ?? null)).slice(0, 400),
989
899
  })
990
900
  // Derived from the workspace, not hardcoded: the fixture used to live at
991
901
  // /tmp/dsh-wsl-selftest.txt, which only worked while the workspace was
@@ -1003,13 +913,13 @@ const METHODS = {
1003
913
  record('tools.read', {
1004
914
  ok: readTool.ok,
1005
915
  error: readTool.error ?? null,
1006
- text: JSON.stringify(readTool.result ?? null).slice(0, 400),
916
+ text: (readTool.text ?? JSON.stringify(readTool.result ?? null)).slice(0, 400),
1007
917
  })
1008
918
  const pwshTool = await invoke('pwsh', { command: 'echo host-side', description: 'selftest: this tool must not exist in a WSL session' })
1009
919
  record('tools.pwsh', {
1010
920
  ok: pwshTool.ok,
1011
921
  error: pwshTool.error ?? null,
1012
- text: JSON.stringify(pwshTool.result ?? null).slice(0, 200),
922
+ text: (pwshTool.text ?? JSON.stringify(pwshTool.result ?? null)).slice(0, 200),
1013
923
  })
1014
924
 
1015
925
  // The realm's own `grep`/`glob`: the host removes both names from a WSL world
@@ -1085,13 +995,13 @@ const METHODS = {
1085
995
  record('tool.search.grep', {
1086
996
  ok: grepSearch.ok && searchFixtureError === null,
1087
997
  error: grepSearch.error ?? searchFixtureError,
1088
- text: JSON.stringify(grepSearch.result ?? null).slice(0, 800),
998
+ text: (grepSearch.text ?? JSON.stringify(grepSearch.result ?? null)).slice(0, 800),
1089
999
  })
1090
1000
  const globSearch = await invoke('glob', { pattern: 'src/**/*.ts' })
1091
1001
  record('tool.search.glob', {
1092
1002
  ok: globSearch.ok && searchFixtureError === null,
1093
1003
  error: globSearch.error ?? searchFixtureError,
1094
- text: JSON.stringify(globSearch.result ?? null).slice(0, 800),
1004
+ text: (globSearch.text ?? JSON.stringify(globSearch.result ?? null)).slice(0, 800),
1095
1005
  })
1096
1006
 
1097
1007
  // A tool resolves its policy from the session's logged permission state
@@ -1148,7 +1058,7 @@ const METHODS = {
1148
1058
  record('tools.bashPersist.a', {
1149
1059
  ok: persistA.ok,
1150
1060
  error: persistA.error ?? null,
1151
- text: JSON.stringify(persistA.result ?? null).slice(0, 400),
1061
+ text: (persistA.text ?? JSON.stringify(persistA.result ?? null)).slice(0, 400),
1152
1062
  })
1153
1063
  const persistB = await invoke('bash', {
1154
1064
  command: `pwd; echo "$DSH_WSL_PERSIST_PROBE"; echo probe > "$HOME/.dsh-wsl-persist-outside"; echo write-exit=$?`,
@@ -1157,7 +1067,7 @@ const METHODS = {
1157
1067
  record('tools.bashPersist.b', {
1158
1068
  ok: persistB.ok,
1159
1069
  error: persistB.error ?? null,
1160
- text: JSON.stringify(persistB.result ?? null).slice(0, 800),
1070
+ text: (persistB.text ?? JSON.stringify(persistB.result ?? null)).slice(0, 800),
1161
1071
  })
1162
1072
 
1163
1073
  // F8's guard, observed from the realm: once a persistent terminal session exists, a
@@ -1216,7 +1126,7 @@ const METHODS = {
1216
1126
  // through this runner; that pin now covers THIS form structurally as well
1217
1127
  // (`insideCall` + `carrier` in the structural row beside the count table), so dropping
1218
1128
  // the spread reddens it instead of silently reverting to the default `wsl.exe`.
1219
- const sweepSelect = { wslPath: routeWslPath }
1129
+ const sweepSelect = { wslPath: hostSession.wslPath }
1220
1130
  const sweepShell = (command) => runWslShell({ distro: sweepDistro, linuxCwd: '/', loginShell: false, command, ...sweepSelect })
1221
1131
  const sweepList = async () => String((await sweepShell('ls -1 /tmp/dsh-pty-*.fifo 2>/dev/null; echo END')).stdout ?? '')
1222
1132
  .split('\n').map((line) => line.trim()).filter((line) => line.startsWith('/tmp/dsh-pty-'))
@@ -1260,7 +1170,7 @@ const METHODS = {
1260
1170
  const [distroSecondsText, distroIso] = String(clock.stdout ?? '').trim().split('\n')
1261
1171
  await sweepPlant()
1262
1172
  const planted = await sweepList()
1263
- const removedFirst = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: routeWslPath })
1173
+ const removedFirst = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: hostSession.wslPath })
1264
1174
  const afterFirst = await sweepList()
1265
1175
  // The host-side shift. `Date.now` is the only host clock this process offers the
1266
1176
  // sweep, so patching it is the whole of "the host's reading changed".
@@ -1280,7 +1190,7 @@ const METHODS = {
1280
1190
  let removedShifted
1281
1191
  try {
1282
1192
  Date.now = () => realNow() + HOST_SHIFT_MS
1283
- removedShifted = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: routeWslPath })
1193
+ removedShifted = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: hostSession.wslPath })
1284
1194
  } finally {
1285
1195
  Date.now = realNow
1286
1196
  }
@@ -1375,17 +1285,17 @@ const METHODS = {
1375
1285
  * @returns {Promise<object>} what each step observed.
1376
1286
  */
1377
1287
  workspaceFlow: async ({ distro, linuxPath }) => {
1378
- const registry = hostCtx.get('workspaceRegistry')
1288
+ const registry = hostSession.ctx.get('workspaceRegistry')
1379
1289
  if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
1380
1290
  // Default the same way the selftest does — the distribution home, never /tmp:
1381
1291
  // the fence refuses a workspace root under /tmp (confinement mounts a private
1382
1292
  // tmpfs over it after binding the workspace), so a workspace registered there
1383
1293
  // could never be used as a writable root. This method's old default handed the
1384
1294
  // registry exactly such a path.
1385
- const location = linuxPath ?? (await defaultWorkspaceUnc({ wslPath: routeWslPath })).uncPath
1295
+ const location = linuxPath ?? (await defaultWorkspaceUnc({ wslPath: hostSession.wslPath })).uncPath
1386
1296
  const target = await resolveLocation(location, distro)
1387
- const listing = await listLinuxDir(target.distro, target.linuxPath, { wslPath: routeWslPath })
1388
- const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: routeWslPath })
1297
+ const listing = await listLinuxDir(target.distro, target.linuxPath, { wslPath: hostSession.wslPath })
1298
+ const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: hostSession.wslPath })
1389
1299
  const existing = await registry.resolveByPath(target.uncPath)
1390
1300
  const workspace = existing ?? await registry.create(target.uncPath, `wsl-selftest ${target.linuxPath}`)
1391
1301
  const created = existing === undefined
@@ -1408,7 +1318,7 @@ const METHODS = {
1408
1318
  * Report recent preset-binding decisions.
1409
1319
  * @returns {Promise<object>} the decisions, newest last.
1410
1320
  */
1411
- bindingLog: () => Promise.resolve({ entries: bindingLog }),
1321
+ bindingLog: () => Promise.resolve({ entries: bindingTape.log }),
1412
1322
 
1413
1323
  /**
1414
1324
  * Read the harness's own view of every preset's composition.
@@ -1418,7 +1328,7 @@ const METHODS = {
1418
1328
  * @returns {Promise<object>} the composition inventory.
1419
1329
  */
1420
1330
  presetInventory: async () => {
1421
- const presets = hostCtx.get('agentPresets')
1331
+ const presets = hostSession.ctx.get('agentPresets')
1422
1332
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
1423
1333
  return { inventory: await presets.compositionInventory() }
1424
1334
  },
@@ -1428,7 +1338,7 @@ const METHODS = {
1428
1338
  * @returns {Promise<object>} the roster.
1429
1339
  */
1430
1340
  presetRoster: async () => {
1431
- const presets = hostCtx.get('agentPresets')
1341
+ const presets = hostSession.ctx.get('agentPresets')
1432
1342
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
1433
1343
  const roster = await presets.list()
1434
1344
  return { roster: roster.map((entry) => ({ id: entry.id, name: entry.name, broken: entry.broken ?? null, isDefault: entry.isDefault ?? false })) }
@@ -1439,7 +1349,7 @@ const METHODS = {
1439
1349
  * @param {{ distro?: string }} params - optional distribution to pin.
1440
1350
  * @returns {Promise<object>} the new state.
1441
1351
  */
1442
- regeneratePresets: ({ distro }) => refreshPresets(hostCtx, distro),
1352
+ regeneratePresets: ({ distro }) => refreshPresets(hostSession.ctx, distro),
1443
1353
 
1444
1354
  /**
1445
1355
  * Create (or find) the workspace for a WSL directory.
@@ -1451,9 +1361,9 @@ const METHODS = {
1451
1361
  */
1452
1362
  createWorkspace: async ({ distro, path, title }) => {
1453
1363
  const target = await resolveLocation(path, distro)
1454
- const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: routeWslPath })
1364
+ const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: hostSession.wslPath })
1455
1365
  if (!facts.isDirectory) throw new Error(`${target.linuxPath} 不是一个存在的目录`)
1456
- const registry = hostCtx.get('workspaceRegistry')
1366
+ const registry = hostSession.ctx.get('workspaceRegistry')
1457
1367
  if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
1458
1368
  const existing = await registry.resolveByPath(target.uncPath)
1459
1369
  const workspace = existing ?? await registry.create(target.uncPath, title ?? target.linuxPath)
@@ -1463,7 +1373,7 @@ const METHODS = {
1463
1373
  // degrades to the default spelling instead of failing the creation.
1464
1374
  let presetId = 'wsl-standard'
1465
1375
  try {
1466
- const presets = hostCtx.get('agentPresets')
1376
+ const presets = hostSession.ctx.get('agentPresets')
1467
1377
  if (presets !== undefined) {
1468
1378
  const resolved = await presets.resolve()
1469
1379
  const candidate = variantIdFor(resolved)
@@ -1492,7 +1402,7 @@ const METHODS = {
1492
1402
  listPresetDirectories: async () => {
1493
1403
  // 0.1.6 read generated directories off disk; the registry is the source of
1494
1404
  // truth now. The method name is kept for the developer surface.
1495
- const presets = hostCtx?.get('agentPresets')
1405
+ const presets = hostSession.ctx?.get('agentPresets')
1496
1406
  if (presets === undefined) return { root: null, names: [] }
1497
1407
  const roster = await presets.list()
1498
1408
  const names = roster
@@ -1511,7 +1421,7 @@ const METHODS = {
1511
1421
  if (typeof id !== 'string' || !/^[A-Za-z0-9._-]+$/.test(id)) {
1512
1422
  throw new Error(`readPreset: 非法的预设 id ${JSON.stringify(id)}`)
1513
1423
  }
1514
- const presets = hostCtx?.get('agentPresets')
1424
+ const presets = hostSession.ctx?.get('agentPresets')
1515
1425
  if (presets === undefined) throw new Error('readPreset: agentPresets 服务不可用')
1516
1426
  // 0.1.7: readDocument returns the declaration re-dumped as YAML content.
1517
1427
  const doc = await presets.readDocument(id)
@@ -1519,102 +1429,17 @@ const METHODS = {
1519
1429
  },
1520
1430
  }
1521
1431
 
1522
- /**
1523
- * Read one JSON request body, bounded.
1524
- * @param {import('node:http').IncomingMessage} req - the request to drain.
1525
- * @returns {Promise<{ text: string | null, byteLength: number }>} the body, or null past the ceiling.
1526
- */
1527
- async function readJsonBody(req) {
1528
- const chunks = []
1529
- let size = 0
1530
- for await (const chunk of req) {
1531
- size += chunk.byteLength
1532
- if (size > MAX_BODY_BYTES) {
1533
- // Drain the remainder so the refusal is a readable response, not a socket cut.
1534
- req.resume()
1535
- return { text: null, byteLength: size }
1536
- }
1537
- chunks.push(chunk)
1538
- }
1539
- return { text: Buffer.concat(chunks, size).toString('utf8'), byteLength: size }
1540
- }
1541
-
1542
- /**
1543
- * Answer one request with a JSON envelope and an explicit status.
1544
- * @param {import('node:http').ServerResponse} res - the response to complete.
1545
- * @param {number} status - the HTTP status.
1546
- * @param {{ ok: boolean }} payload - the envelope, success or failure.
1547
- */
1548
- function sendJson(res, status, payload) {
1549
- const body = JSON.stringify(payload)
1550
- res.writeHead(status, {
1551
- 'content-type': 'application/json; charset=utf-8',
1552
- 'content-length': Buffer.byteLength(body),
1553
- 'cache-control': 'no-store',
1554
- })
1555
- res.end(body)
1556
- }
1557
-
1558
- /**
1559
- * Answer with one admission rejection.
1560
- * @param {import('node:http').ServerResponse} res - the response to complete.
1561
- * @param {import('./http-admission.js').Rejection} rejection - the refusal.
1562
- */
1563
- function sendRejection(res, rejection) {
1564
- sendJson(res, rejection.status, { ok: false, code: rejection.code, error: rejection.message })
1565
- }
1566
-
1567
- /** Trust surface consumed here; the browser-side connection package owns the full type. */
1568
- function connectionOf(ctx) {
1569
- return Reflect.get(ctx, 'connection')
1570
- }
1571
-
1572
- /**
1573
- * This installation's development token, minted on first use.
1574
- *
1575
- * The file is owner-only and lives outside the repository. A web page cannot
1576
- * read it, which is what keeps the acceptance surface unreachable from a
1577
- * cross-site request even though the route itself is on loopback.
1578
- * @returns {Promise<string>} the token.
1579
- */
1580
- async function devToken() {
1581
- const path = join(dshHome(), 'wsl-desktop-dev-token')
1582
- try {
1583
- const existing = (await readFile(path, 'utf8')).trim()
1584
- if (existing.length > 0) return existing
1585
- // An empty file is unusual — treat it like absent and re-mint below.
1586
- } catch (error) {
1587
- // ENOENT = first use. Any other read failure (ACL, AV lock, disk) must
1588
- // fail loud rather than silently rotate the credential and revoke every
1589
- // scripted caller.
1590
- if (error?.code !== 'ENOENT') throw error
1591
- }
1592
- const minted = randomBytes(32).toString('hex')
1593
- await mkdir(dirname(path), { recursive: true })
1594
- await writeFile(path, `${minted}\n`, { mode: 0o600 })
1595
- return minted
1596
- }
1597
-
1598
- /**
1599
- * Whether one request may use the acceptance surface.
1600
- * @param {import('node:http').IncomingMessage} req - the request.
1601
- * @param {{ developerTools: boolean }} config - resolved plugin config.
1602
- * @returns {Promise<boolean>} true for a caller presenting this installation's token.
1603
- */
1604
- async function isDeveloperCaller(req, config) {
1605
- if (config.developerTools !== true) return false
1606
- return tokenMatches(req.headers[DEV_TOKEN_HEADER], await devToken(), timingSafeEqual)
1607
- }
1608
-
1609
1432
  /**
1610
1433
  * Register the WSL route and generate the WSL presets.
1611
1434
  * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
1612
1435
  * @param {{ routePath: string, developerTools: boolean, wslPath?: string }} config - resolved plugin config.
1613
1436
  */
1614
1437
  export function apply(ctx, config) {
1615
- hostCtx = ctx
1616
- routeWslPath = config.wslPath
1617
- hostReady = false
1438
+ hostSession.ctx = ctx
1439
+ // The declared default, not `undefined`: every route method passes this to
1440
+ // wsl.exe, and an undefined path is not the same as the default one.
1441
+ hostSession.wslPath = config.wslPath ?? 'wsl.exe'
1442
+ hostSession.ready = false
1618
1443
  // Bound concurrent route methods: each discovery call spawns wsl.exe, and a
1619
1444
  // misbehaving page could fan out without limit. Named because a bare "8" here
1620
1445
  // was once mis-read as a wsl.exe SPAWN cap — it is not: one route method may
@@ -1744,10 +1569,10 @@ export function apply(ctx, config) {
1744
1569
  // The probe is allowed to run even after a failed materialization: the result
1745
1570
  // then says which capability is missing, instead of leaving the browser on
1746
1571
  // "pending" forever.
1747
- hostReady = true
1572
+ hostSession.ready = true
1748
1573
  // One startup line, as the spec asks: the post-restart gate and a bug report
1749
1574
  // both need the capability state without opening the dialog.
1750
- void probeHost(hostCtx).then((status) => {
1575
+ void probeHost(hostSession.ctx).then((status) => {
1751
1576
  ctx.logger?.info?.(`wsl-desktop: capabilities ${JSON.stringify(status)}`)
1752
1577
  }, () => {})
1753
1578
  })
@@ -1758,7 +1583,7 @@ export function apply(ctx, config) {
1758
1583
  // This listener is the LAST RESORT for a WSL-workspace session created
1759
1584
  // without a preset (some other entry point): it tries a post-hoc select,
1760
1585
  // which is refused for any session that has already taken a turn, and both
1761
- // outcomes land in `bindingLog` as an anomaly signal.
1586
+ // outcomes land in `bindingTape.log` as an anomaly signal.
1762
1587
  //
1763
1588
  // ONE listener, not two. `agent/created` is a scoped event, but a listener
1764
1589
  // registered without a scope is admitted globally, so registering both paths
@@ -1776,8 +1601,7 @@ export function apply(ctx, config) {
1776
1601
  * @param {object} entry - the decision to record.
1777
1602
  */
1778
1603
  function recordBinding(entry) {
1779
- bindingLog.push(entry)
1780
- if (bindingLog.length > BINDING_LOG_LIMIT) bindingLog.splice(0, bindingLog.length - BINDING_LOG_LIMIT)
1604
+ bindingTape.record(entry)
1781
1605
  }
1782
1606
 
1783
1607
  /**