dsh-wsl-desktop 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -896,11 +782,17 @@ const METHODS = {
896
782
  // Created through the shell this block already runs its mutations with. A create
897
783
  // that FAILED must not read as a false green, so its outcome is reported in the
898
784
  // row: an absent path would notify from its arming baseline and pass on nothing.
899
- const probeCreated = await runShellSpec(shell, shell.resolve({ command: `printf 'x\\n' > '${fs.processPath(fileTarget)}'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
785
+ // The path is shellQuote'd, not interpolated inside hand-written quotes: the Linux
786
+ // path grammar refuses control characters and backslashes but NOT an apostrophe
787
+ // (measured), so `'${path}'` would end the word early and the remainder would run
788
+ // as shell syntax. Every other command in this file already goes through the
789
+ // helper; these four were the exception.
790
+ const probePath = shellQuote(fs.processPath(fileTarget))
791
+ const probeCreated = await runShellSpec(shell, shell.resolve({ command: `printf 'x\\n' > ${probePath}`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
900
792
  .then((execution) => execution.result())
901
793
  .catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
902
794
  record('fs.watch.file', probeCreated.exitCode === 0
903
- ? await observeWatch(fileTarget, `printf 'x\\n' >> '${fs.processPath(fileTarget)}'`)
795
+ ? await observeWatch(fileTarget, `printf 'x\\n' >> ${probePath}`)
904
796
  : {
905
797
  notified: 0,
906
798
  watchError: `the probe file could not be created: exit ${probeCreated.exitCode ?? 'null'} ${probeCreated.stderr?.text?.slice(0, 200) ?? ''}`.trim(),
@@ -908,9 +800,10 @@ const METHODS = {
908
800
  })
909
801
 
910
802
  const dirTarget = await fs.resolve('.', { cwd: workspaceCwd })
911
- const dirCommand = `printf 'x\\n' > '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`
803
+ const dirEntry = shellQuote(`${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt`)
804
+ const dirCommand = `printf 'x\\n' > ${dirEntry}`
912
805
  record('fs.watch.dir', await observeWatch(dirTarget, dirCommand))
913
- await runShellSpec(shell, shell.resolve({ command: `rm -f '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
806
+ await runShellSpec(shell, shell.resolve({ command: `rm -f ${dirEntry}`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
914
807
  .then((execution) => execution.result()).catch(() => {})
915
808
 
916
809
  // Why the override exists, recorded rather than assumed: if a future host or
@@ -958,9 +851,21 @@ const METHODS = {
958
851
 
959
852
  // The model calls tools, not providers. Driving the registry is what makes
960
853
  // the acceptance cover the layer a session actually uses.
961
- const tools = hostCtx.get('tools')
854
+ const tools = hostSession.ctx.get('tools')
962
855
  if (tools !== undefined && agent !== undefined) {
963
856
  const controller = new AbortController()
857
+ /**
858
+ * The text one tool outcome reports, capped for the receipt.
859
+ *
860
+ * The `.text`-first rule lives here rather than repeated at seven call sites: every
861
+ * row wants the tool's OWN output, and falls back to the serialized result only when
862
+ * the outcome carries no text (a failure has no `value` to serialize). The cap
863
+ * differs per row because the rows read different amounts of that output.
864
+ * @param {{ text?: string, result?: unknown }} outcome - one `invoke` result.
865
+ * @param {number} limit - maximum characters kept.
866
+ * @returns {string} the text to record.
867
+ */
868
+ const outcomeText = (outcome, limit) => (outcome.text ?? JSON.stringify(outcome.result ?? null)).slice(0, limit)
964
869
  /**
965
870
  * Execute one tool on the session's behalf.
966
871
  * @param {string} toolName - registered tool name.
@@ -969,14 +874,47 @@ const METHODS = {
969
874
  */
970
875
  const invoke = async (toolName, args) => {
971
876
  try {
972
- const result = await tools.execute({
973
- callId: `wsl-selftest-${toolName}-${Date.now()}`,
974
- name: toolName,
975
- arguments: args,
877
+ // Host 0.2.1-rc.3 changed `tools.execute` from ONE call to a BATCH: it now
878
+ // takes `{ calls: ToolCallBlock[], signal, agent? }` and answers
879
+ // `{ results, additionalContexts, concluded }`. The previous single-call
880
+ // object is rejected with `ToolBatchExecutionError` ("Tool batch execution
881
+ // failed"), which every tool row below reported as a null result.
882
+ //
883
+ // `ToolCallBlock.arguments` is the RAW JSON STRING the model produces, not an
884
+ // object — the block is the wire shape, not a convenience wrapper.
885
+ const batch = await tools.execute({
886
+ calls: [{
887
+ type: 'tool-call',
888
+ id: `wsl-selftest-${toolName}-${Date.now()}`,
889
+ name: toolName,
890
+ arguments: JSON.stringify(args ?? {}),
891
+ }],
976
892
  agent,
977
893
  signal: controller.signal,
978
894
  })
979
- return { ok: true, result: JSON.parse(JSON.stringify(result ?? null)) }
895
+ const outcome = batch?.results?.[0]
896
+ if (outcome === undefined) return { ok: false, error: 'the batch returned no result for this call' }
897
+ // `ToolCallResult` is `{ callId, result: ToolExecutionResult }`, and
898
+ // `ToolExecutionResult` is a DISCRIMINATED UNION on `isError`: a success carries
899
+ // `{ isError: false, value }` and a failure carries `{ isError: true, error }`
900
+ // where `error` is a `ToolFailure { message, info? }` and `value` is `never`.
901
+ // Switching on the discriminant is the contract; probing for `error` on a
902
+ // success is reading a field the type says cannot be there.
903
+ const execution = outcome.result
904
+ if (execution === undefined || execution === null) {
905
+ return { ok: false, error: 'the batch returned a result-shaped null for this call', text: 'null' }
906
+ }
907
+ // Branch ONCE on the discriminant. A failure has no `value` to serialize, so its
908
+ // own message is what the row reports as text — which is also what makes a failed
909
+ // row readable instead of showing `"null"`.
910
+ if (execution.isError === true) {
911
+ const message = String(execution.error?.message ?? 'the tool reported an error')
912
+ return { ok: false, error: message, text: message }
913
+ }
914
+ const text = typeof execution.value === 'string'
915
+ ? execution.value
916
+ : JSON.stringify(execution.value ?? null)
917
+ return { ok: true, result: JSON.parse(JSON.stringify(execution)), text }
980
918
  } catch (error) {
981
919
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
982
920
  }
@@ -985,7 +923,7 @@ const METHODS = {
985
923
  record('tools.bash', {
986
924
  ok: bashTool.ok,
987
925
  error: bashTool.error ?? null,
988
- text: JSON.stringify(bashTool.result ?? null).slice(0, 400),
926
+ text: outcomeText(bashTool, 400),
989
927
  })
990
928
  // Derived from the workspace, not hardcoded: the fixture used to live at
991
929
  // /tmp/dsh-wsl-selftest.txt, which only worked while the workspace was
@@ -1003,13 +941,13 @@ const METHODS = {
1003
941
  record('tools.read', {
1004
942
  ok: readTool.ok,
1005
943
  error: readTool.error ?? null,
1006
- text: JSON.stringify(readTool.result ?? null).slice(0, 400),
944
+ text: outcomeText(readTool, 400),
1007
945
  })
1008
946
  const pwshTool = await invoke('pwsh', { command: 'echo host-side', description: 'selftest: this tool must not exist in a WSL session' })
1009
947
  record('tools.pwsh', {
1010
948
  ok: pwshTool.ok,
1011
949
  error: pwshTool.error ?? null,
1012
- text: JSON.stringify(pwshTool.result ?? null).slice(0, 200),
950
+ text: outcomeText(pwshTool, 200),
1013
951
  })
1014
952
 
1015
953
  // The realm's own `grep`/`glob`: the host removes both names from a WSL world
@@ -1085,13 +1023,13 @@ const METHODS = {
1085
1023
  record('tool.search.grep', {
1086
1024
  ok: grepSearch.ok && searchFixtureError === null,
1087
1025
  error: grepSearch.error ?? searchFixtureError,
1088
- text: JSON.stringify(grepSearch.result ?? null).slice(0, 800),
1026
+ text: outcomeText(grepSearch, 800),
1089
1027
  })
1090
1028
  const globSearch = await invoke('glob', { pattern: 'src/**/*.ts' })
1091
1029
  record('tool.search.glob', {
1092
1030
  ok: globSearch.ok && searchFixtureError === null,
1093
1031
  error: globSearch.error ?? searchFixtureError,
1094
- text: JSON.stringify(globSearch.result ?? null).slice(0, 800),
1032
+ text: outcomeText(globSearch, 800),
1095
1033
  })
1096
1034
 
1097
1035
  // A tool resolves its policy from the session's logged permission state
@@ -1148,7 +1086,7 @@ const METHODS = {
1148
1086
  record('tools.bashPersist.a', {
1149
1087
  ok: persistA.ok,
1150
1088
  error: persistA.error ?? null,
1151
- text: JSON.stringify(persistA.result ?? null).slice(0, 400),
1089
+ text: outcomeText(persistA, 400),
1152
1090
  })
1153
1091
  const persistB = await invoke('bash', {
1154
1092
  command: `pwd; echo "$DSH_WSL_PERSIST_PROBE"; echo probe > "$HOME/.dsh-wsl-persist-outside"; echo write-exit=$?`,
@@ -1157,7 +1095,7 @@ const METHODS = {
1157
1095
  record('tools.bashPersist.b', {
1158
1096
  ok: persistB.ok,
1159
1097
  error: persistB.error ?? null,
1160
- text: JSON.stringify(persistB.result ?? null).slice(0, 800),
1098
+ text: outcomeText(persistB, 800),
1161
1099
  })
1162
1100
 
1163
1101
  // F8's guard, observed from the realm: once a persistent terminal session exists, a
@@ -1216,7 +1154,7 @@ const METHODS = {
1216
1154
  // through this runner; that pin now covers THIS form structurally as well
1217
1155
  // (`insideCall` + `carrier` in the structural row beside the count table), so dropping
1218
1156
  // the spread reddens it instead of silently reverting to the default `wsl.exe`.
1219
- const sweepSelect = { wslPath: routeWslPath }
1157
+ const sweepSelect = { wslPath: hostSession.wslPath }
1220
1158
  const sweepShell = (command) => runWslShell({ distro: sweepDistro, linuxCwd: '/', loginShell: false, command, ...sweepSelect })
1221
1159
  const sweepList = async () => String((await sweepShell('ls -1 /tmp/dsh-pty-*.fifo 2>/dev/null; echo END')).stdout ?? '')
1222
1160
  .split('\n').map((line) => line.trim()).filter((line) => line.startsWith('/tmp/dsh-pty-'))
@@ -1260,7 +1198,7 @@ const METHODS = {
1260
1198
  const [distroSecondsText, distroIso] = String(clock.stdout ?? '').trim().split('\n')
1261
1199
  await sweepPlant()
1262
1200
  const planted = await sweepList()
1263
- const removedFirst = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: routeWslPath })
1201
+ const removedFirst = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: hostSession.wslPath })
1264
1202
  const afterFirst = await sweepList()
1265
1203
  // The host-side shift. `Date.now` is the only host clock this process offers the
1266
1204
  // sweep, so patching it is the whole of "the host's reading changed".
@@ -1280,7 +1218,7 @@ const METHODS = {
1280
1218
  let removedShifted
1281
1219
  try {
1282
1220
  Date.now = () => realNow() + HOST_SHIFT_MS
1283
- removedShifted = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: routeWslPath })
1221
+ removedShifted = await sweepOrphanFifos({ distro: sweepDistro }, { wslPath: hostSession.wslPath })
1284
1222
  } finally {
1285
1223
  Date.now = realNow
1286
1224
  }
@@ -1375,17 +1313,17 @@ const METHODS = {
1375
1313
  * @returns {Promise<object>} what each step observed.
1376
1314
  */
1377
1315
  workspaceFlow: async ({ distro, linuxPath }) => {
1378
- const registry = hostCtx.get('workspaceRegistry')
1316
+ const registry = hostSession.ctx.get('workspaceRegistry')
1379
1317
  if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
1380
1318
  // Default the same way the selftest does — the distribution home, never /tmp:
1381
1319
  // the fence refuses a workspace root under /tmp (confinement mounts a private
1382
1320
  // tmpfs over it after binding the workspace), so a workspace registered there
1383
1321
  // could never be used as a writable root. This method's old default handed the
1384
1322
  // registry exactly such a path.
1385
- const location = linuxPath ?? (await defaultWorkspaceUnc({ wslPath: routeWslPath })).uncPath
1323
+ const location = linuxPath ?? (await defaultWorkspaceUnc({ wslPath: hostSession.wslPath })).uncPath
1386
1324
  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 })
1325
+ const listing = await listLinuxDir(target.distro, target.linuxPath, { wslPath: hostSession.wslPath })
1326
+ const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: hostSession.wslPath })
1389
1327
  const existing = await registry.resolveByPath(target.uncPath)
1390
1328
  const workspace = existing ?? await registry.create(target.uncPath, `wsl-selftest ${target.linuxPath}`)
1391
1329
  const created = existing === undefined
@@ -1408,7 +1346,7 @@ const METHODS = {
1408
1346
  * Report recent preset-binding decisions.
1409
1347
  * @returns {Promise<object>} the decisions, newest last.
1410
1348
  */
1411
- bindingLog: () => Promise.resolve({ entries: bindingLog }),
1349
+ bindingLog: () => Promise.resolve({ entries: bindingTape.log }),
1412
1350
 
1413
1351
  /**
1414
1352
  * Read the harness's own view of every preset's composition.
@@ -1418,7 +1356,7 @@ const METHODS = {
1418
1356
  * @returns {Promise<object>} the composition inventory.
1419
1357
  */
1420
1358
  presetInventory: async () => {
1421
- const presets = hostCtx.get('agentPresets')
1359
+ const presets = hostSession.ctx.get('agentPresets')
1422
1360
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
1423
1361
  return { inventory: await presets.compositionInventory() }
1424
1362
  },
@@ -1428,7 +1366,7 @@ const METHODS = {
1428
1366
  * @returns {Promise<object>} the roster.
1429
1367
  */
1430
1368
  presetRoster: async () => {
1431
- const presets = hostCtx.get('agentPresets')
1369
+ const presets = hostSession.ctx.get('agentPresets')
1432
1370
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
1433
1371
  const roster = await presets.list()
1434
1372
  return { roster: roster.map((entry) => ({ id: entry.id, name: entry.name, broken: entry.broken ?? null, isDefault: entry.isDefault ?? false })) }
@@ -1439,7 +1377,7 @@ const METHODS = {
1439
1377
  * @param {{ distro?: string }} params - optional distribution to pin.
1440
1378
  * @returns {Promise<object>} the new state.
1441
1379
  */
1442
- regeneratePresets: ({ distro }) => refreshPresets(hostCtx, distro),
1380
+ regeneratePresets: ({ distro }) => refreshPresets(hostSession.ctx, distro),
1443
1381
 
1444
1382
  /**
1445
1383
  * Create (or find) the workspace for a WSL directory.
@@ -1451,9 +1389,9 @@ const METHODS = {
1451
1389
  */
1452
1390
  createWorkspace: async ({ distro, path, title }) => {
1453
1391
  const target = await resolveLocation(path, distro)
1454
- const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: routeWslPath })
1392
+ const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: hostSession.wslPath })
1455
1393
  if (!facts.isDirectory) throw new Error(`${target.linuxPath} 不是一个存在的目录`)
1456
- const registry = hostCtx.get('workspaceRegistry')
1394
+ const registry = hostSession.ctx.get('workspaceRegistry')
1457
1395
  if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
1458
1396
  const existing = await registry.resolveByPath(target.uncPath)
1459
1397
  const workspace = existing ?? await registry.create(target.uncPath, title ?? target.linuxPath)
@@ -1463,7 +1401,7 @@ const METHODS = {
1463
1401
  // degrades to the default spelling instead of failing the creation.
1464
1402
  let presetId = 'wsl-standard'
1465
1403
  try {
1466
- const presets = hostCtx.get('agentPresets')
1404
+ const presets = hostSession.ctx.get('agentPresets')
1467
1405
  if (presets !== undefined) {
1468
1406
  const resolved = await presets.resolve()
1469
1407
  const candidate = variantIdFor(resolved)
@@ -1492,7 +1430,7 @@ const METHODS = {
1492
1430
  listPresetDirectories: async () => {
1493
1431
  // 0.1.6 read generated directories off disk; the registry is the source of
1494
1432
  // truth now. The method name is kept for the developer surface.
1495
- const presets = hostCtx?.get('agentPresets')
1433
+ const presets = hostSession.ctx?.get('agentPresets')
1496
1434
  if (presets === undefined) return { root: null, names: [] }
1497
1435
  const roster = await presets.list()
1498
1436
  const names = roster
@@ -1511,7 +1449,7 @@ const METHODS = {
1511
1449
  if (typeof id !== 'string' || !/^[A-Za-z0-9._-]+$/.test(id)) {
1512
1450
  throw new Error(`readPreset: 非法的预设 id ${JSON.stringify(id)}`)
1513
1451
  }
1514
- const presets = hostCtx?.get('agentPresets')
1452
+ const presets = hostSession.ctx?.get('agentPresets')
1515
1453
  if (presets === undefined) throw new Error('readPreset: agentPresets 服务不可用')
1516
1454
  // 0.1.7: readDocument returns the declaration re-dumped as YAML content.
1517
1455
  const doc = await presets.readDocument(id)
@@ -1519,102 +1457,17 @@ const METHODS = {
1519
1457
  },
1520
1458
  }
1521
1459
 
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
1460
  /**
1610
1461
  * Register the WSL route and generate the WSL presets.
1611
1462
  * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
1612
1463
  * @param {{ routePath: string, developerTools: boolean, wslPath?: string }} config - resolved plugin config.
1613
1464
  */
1614
1465
  export function apply(ctx, config) {
1615
- hostCtx = ctx
1616
- routeWslPath = config.wslPath
1617
- hostReady = false
1466
+ hostSession.ctx = ctx
1467
+ // The declared default, not `undefined`: every route method passes this to
1468
+ // wsl.exe, and an undefined path is not the same as the default one.
1469
+ hostSession.wslPath = config.wslPath ?? 'wsl.exe'
1470
+ hostSession.ready = false
1618
1471
  // Bound concurrent route methods: each discovery call spawns wsl.exe, and a
1619
1472
  // misbehaving page could fan out without limit. Named because a bare "8" here
1620
1473
  // was once mis-read as a wsl.exe SPAWN cap — it is not: one route method may
@@ -1744,10 +1597,10 @@ export function apply(ctx, config) {
1744
1597
  // The probe is allowed to run even after a failed materialization: the result
1745
1598
  // then says which capability is missing, instead of leaving the browser on
1746
1599
  // "pending" forever.
1747
- hostReady = true
1600
+ hostSession.ready = true
1748
1601
  // One startup line, as the spec asks: the post-restart gate and a bug report
1749
1602
  // both need the capability state without opening the dialog.
1750
- void probeHost(hostCtx).then((status) => {
1603
+ void probeHost(hostSession.ctx).then((status) => {
1751
1604
  ctx.logger?.info?.(`wsl-desktop: capabilities ${JSON.stringify(status)}`)
1752
1605
  }, () => {})
1753
1606
  })
@@ -1758,7 +1611,7 @@ export function apply(ctx, config) {
1758
1611
  // This listener is the LAST RESORT for a WSL-workspace session created
1759
1612
  // without a preset (some other entry point): it tries a post-hoc select,
1760
1613
  // which is refused for any session that has already taken a turn, and both
1761
- // outcomes land in `bindingLog` as an anomaly signal.
1614
+ // outcomes land in `bindingTape.log` as an anomaly signal.
1762
1615
  //
1763
1616
  // ONE listener, not two. `agent/created` is a scoped event, but a listener
1764
1617
  // registered without a scope is admitted globally, so registering both paths
@@ -1776,8 +1629,7 @@ export function apply(ctx, config) {
1776
1629
  * @param {object} entry - the decision to record.
1777
1630
  */
1778
1631
  function recordBinding(entry) {
1779
- bindingLog.push(entry)
1780
- if (bindingLog.length > BINDING_LOG_LIMIT) bindingLog.splice(0, bindingLog.length - BINDING_LOG_LIMIT)
1632
+ bindingTape.record(entry)
1781
1633
  }
1782
1634
 
1783
1635
  /**