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/LICENSE +21 -0
- package/README.md +222 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +709 -0
- package/lib/http-admission.js +102 -0
- package/lib/index.js +1067 -0
- package/lib/wsl/confinement.js +424 -0
- package/lib/wsl/dsh-wsl-confine.sh +80 -0
- package/lib/wsl/fence.js +121 -0
- package/lib/wsl/fs.js +324 -0
- package/lib/wsl/host-refs.js +38 -0
- package/lib/wsl/paths.js +95 -0
- package/lib/wsl/preset.js +456 -0
- package/lib/wsl/pty.js +366 -0
- package/lib/wsl/shell.js +462 -0
- package/lib/wsl/subprocess.js +195 -0
- package/lib/wsl/terminal-bridge.py +278 -0
- package/lib/wsl/world.js +458 -0
- package/package.json +35 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the WSL agent preset from a shipped one.
|
|
3
|
+
*
|
|
4
|
+
* A session's execution world is chosen by its preset, not by a global router:
|
|
5
|
+
* the model also needs a different tool dialect (bash rather than PowerShell),
|
|
6
|
+
* and one process-wide provider could not vary that per session. This module
|
|
7
|
+
* therefore rewrites a shipped preset's entry list — it removes the rows that
|
|
8
|
+
* name the host execution world and appends one `isolate` group that mounts the
|
|
9
|
+
* WSL world plus the tools that consume it.
|
|
10
|
+
*
|
|
11
|
+
* The transform is pure text over the loader's entry list, so it is unit
|
|
12
|
+
* testable without the harness. Rows are matched by their top-level `- id:`
|
|
13
|
+
* boundary; only top-level (two-space indented) keys are rewritten, so a nested
|
|
14
|
+
* entry inside a group's `config:` is never touched.
|
|
15
|
+
* @module dsh-wsl-desktop/wsl/preset
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Decide what happens to one directory inside this plugin's preset namespace.
|
|
23
|
+
*
|
|
24
|
+
* The namespace is a name prefix, so it is shared with anything a user chooses
|
|
25
|
+
* to call `wsl-…`. Only a directory this plugin wrote — one carrying its marker
|
|
26
|
+
* — may be withdrawn; an unmarked one is the user's and is reported instead of
|
|
27
|
+
* deleted.
|
|
28
|
+
* @param {{ name: string, prefix: string, expected: boolean, marked: boolean }} entry - one directory.
|
|
29
|
+
* @returns {'ignore' | 'keep' | 'withdraw' | 'unmanaged'} the disposition.
|
|
30
|
+
*/
|
|
31
|
+
export function sweepDecision({ name, prefix, expected, marked }) {
|
|
32
|
+
if (!name.startsWith(prefix)) return 'ignore'
|
|
33
|
+
if (expected) return 'keep'
|
|
34
|
+
return marked ? 'withdraw' : 'unmanaged'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Rows that name the host execution world and must not survive in a WSL preset. */
|
|
38
|
+
export const WORLD_ROWS = new Set([
|
|
39
|
+
'tool-bash',
|
|
40
|
+
'tool-pwsh',
|
|
41
|
+
'tool-fs',
|
|
42
|
+
'tool-fs-search',
|
|
43
|
+
'str-replace-editor',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Host-execution-world MODULE names, matched against a row's `name` at every
|
|
48
|
+
* depth. Beyond the five canonical rows, the persistent/terminal family ships
|
|
49
|
+
* NESTED in the minimal preset's `persistent-shell` group with non-canonical
|
|
50
|
+
* ids — an enabled host PowerShell persistent tool inside a preset whose name
|
|
51
|
+
* and persona assert distro-contained execution. `tool-bash`/`tool-fs` are
|
|
52
|
+
* deliberately absent: those names are re-mounted in their WSL form inside
|
|
53
|
+
* the wsl-world group; their host-world forms are removed by id.
|
|
54
|
+
*/
|
|
55
|
+
export const WORLD_MODULE_NAMES = new Set([
|
|
56
|
+
'@deepseek-ai/dsh-tool-pwsh',
|
|
57
|
+
'@deepseek-ai/dsh-tool-fs-search',
|
|
58
|
+
'@deepseek-ai/dsh-tool-str-replace-editor',
|
|
59
|
+
'@deepseek-ai/dsh-tool-bash-persistent',
|
|
60
|
+
'@deepseek-ai/dsh-tool-pwsh-persistent',
|
|
61
|
+
'@deepseek-ai/dsh-terminal-bash',
|
|
62
|
+
'@deepseek-ai/dsh-terminal',
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether one row `name` loads a host-execution-world module. Matches the
|
|
67
|
+
* bare package specifier and file-URL/path spellings ending in the package
|
|
68
|
+
* basename (with or without .js).
|
|
69
|
+
* @param {string} name - the row's module name.
|
|
70
|
+
* @returns {boolean} true when the module belongs to the host execution world.
|
|
71
|
+
*/
|
|
72
|
+
export function isHostWorldModule(name) {
|
|
73
|
+
if (WORLD_MODULE_NAMES.has(name)) return true
|
|
74
|
+
const base = name.split(/[\\/]/).pop() ?? ''
|
|
75
|
+
const stem = base.replace(/\.js$/u, '')
|
|
76
|
+
return WORLD_MODULE_NAMES.has(stem)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the `wsl-world` isolate group as a plugin-entry OBJECT.
|
|
81
|
+
*
|
|
82
|
+
* 0.1.7 presets are registered programmatically
|
|
83
|
+
* (`agentPresets.register({ id, name, plugins })`) with entry objects rather
|
|
84
|
+
* than written as YAML files — the group's shape mirrors the loader's entry
|
|
85
|
+
* options: `{ id?, name, group?, isolate?, config? }`.
|
|
86
|
+
* @param {object} options - generation options.
|
|
87
|
+
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
88
|
+
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
89
|
+
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
90
|
+
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
91
|
+
* @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
|
|
92
|
+
* @returns {object} the group entry.
|
|
93
|
+
*/
|
|
94
|
+
export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }) {
|
|
95
|
+
const distroConfig = distro === undefined ? {} : { distro }
|
|
96
|
+
const config = [
|
|
97
|
+
{ id: 'subprocess-wsl', name: subprocessPath, ...distroConfig },
|
|
98
|
+
{ id: 'shell-wsl', name: shellPath, ...distroConfig },
|
|
99
|
+
{ id: 'fs-wsl', name: fsPath, ...distroConfig },
|
|
100
|
+
// No terminal registry row: the Web terminal controller spawns through
|
|
101
|
+
// `agent.ctx.get('subprocess').spawnTerminal(...)`, so the realm's
|
|
102
|
+
// subprocess provider already puts the PTY inside the distribution.
|
|
103
|
+
{ id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash' },
|
|
104
|
+
{ id: 'tool-fs', name: '@deepseek-ai/dsh-tool-fs' },
|
|
105
|
+
...(includeEditor ? [{ id: 'str-replace-editor', name: '@deepseek-ai/dsh-tool-str-replace-editor' }] : []),
|
|
106
|
+
]
|
|
107
|
+
return {
|
|
108
|
+
id: 'wsl-world',
|
|
109
|
+
name: 'cordis:group',
|
|
110
|
+
group: true,
|
|
111
|
+
isolate: { shell: true, fs: true, subprocess: true },
|
|
112
|
+
config,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Append the WSL path-dialect sentence to a persona row OBJECT.
|
|
118
|
+
* @param {object} row - the persona entry.
|
|
119
|
+
* @returns {boolean} whether the row was amended.
|
|
120
|
+
*/
|
|
121
|
+
function appendPersonaSuffixObject(row) {
|
|
122
|
+
const config = row.config ?? (row.config = {})
|
|
123
|
+
for (const field of ['suffix', 'text', 'prefix']) {
|
|
124
|
+
if (typeof config[field] === 'string' && config[field].length > 0) {
|
|
125
|
+
config[field] = `${config[field]} ${WSL_PERSONA_SENTENCE}`
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
config.suffix = WSL_PERSONA_SENTENCE
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Transform a base preset's plugin ENTRY OBJECTS into the WSL variant's list.
|
|
135
|
+
*
|
|
136
|
+
* Same semantics as the historical YAML rewrite: drop the rows that name the
|
|
137
|
+
* host execution world, amend the persona with the path-dialect sentence,
|
|
138
|
+
* rewrite relative row names against the source directory, and append the
|
|
139
|
+
* `wsl-world` isolate group.
|
|
140
|
+
* @param {readonly object[]} basePlugins - the base preset's plugin rows.
|
|
141
|
+
* @param {object} options - generation options.
|
|
142
|
+
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
143
|
+
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
144
|
+
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
145
|
+
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
146
|
+
* @param {string | undefined} options.sourceDir - directory the source preset lives in.
|
|
147
|
+
* @returns {{ plugins: object[], removed: string[] }} the variant rows and what was dropped.
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* Clone one entry row deeply enough to transform it safely. Group rows carry
|
|
151
|
+
* their children under `config` as an ARRAY (recursive tree), so the clone
|
|
152
|
+
* must preserve arrays as arrays — a naive `{...config}` spread would turn a
|
|
153
|
+
* group's child list into an index-keyed object and the entry-list validator
|
|
154
|
+
* would refuse the row ("must hold a list of plugin rows").
|
|
155
|
+
* @param {object} row - the row to clone.
|
|
156
|
+
* @returns {object} the cloned row.
|
|
157
|
+
*/
|
|
158
|
+
function cloneRow(row) {
|
|
159
|
+
const clone = { ...row }
|
|
160
|
+
if (Array.isArray(clone.config)) {
|
|
161
|
+
clone.config = clone.config.map((child) => (child !== null && typeof child === 'object' ? cloneRow(child) : child))
|
|
162
|
+
} else if (clone.config !== null && typeof clone.config === 'object') {
|
|
163
|
+
clone.config = { ...clone.config }
|
|
164
|
+
}
|
|
165
|
+
return clone
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, distro, sourceDir }) {
|
|
169
|
+
const kept = []
|
|
170
|
+
const removed = []
|
|
171
|
+
let includeEditor = false
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Recursively prune host-execution-world rows from one entry. Classified by
|
|
175
|
+
* module name at EVERY depth (row ids are optional and arbitrary — the
|
|
176
|
+
* shipped minimal preset nests enabled host PowerShell rows with
|
|
177
|
+
* non-canonical ids inside a group), with the id fast path kept for
|
|
178
|
+
* canonical rows. A group whose children are all pruned is dropped: it
|
|
179
|
+
* holds nothing. Returns the cloned+pruned row, or null when removed.
|
|
180
|
+
*/
|
|
181
|
+
const prune = (row) => {
|
|
182
|
+
if (row === null || typeof row !== 'object') return row
|
|
183
|
+
if (row.id !== undefined && WORLD_ROWS.has(row.id)) {
|
|
184
|
+
removed.push(row.id)
|
|
185
|
+
if (row.id === 'str-replace-editor') includeEditor = true
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
if (typeof row.name === 'string' && isHostWorldModule(row.name)) {
|
|
189
|
+
removed.push(row.id ?? row.name)
|
|
190
|
+
if (typeof row.name === 'string' && row.name.includes('str-replace-editor')) includeEditor = true
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
// The registry stores the caller's objects BY REFERENCE — mutating a row
|
|
194
|
+
// here would pollute the shared base definition. Clone per row;
|
|
195
|
+
// structuredClone is not safe: loader rows may carry JsExpr nodes.
|
|
196
|
+
const clone = cloneRow(row)
|
|
197
|
+
if (clone.group === true && Array.isArray(clone.config)) {
|
|
198
|
+
const children = []
|
|
199
|
+
for (const child of clone.config) {
|
|
200
|
+
const prunedChild = prune(child)
|
|
201
|
+
if (prunedChild !== null) children.push(prunedChild)
|
|
202
|
+
}
|
|
203
|
+
if (children.length === 0) {
|
|
204
|
+
removed.push(clone.id ?? clone.name ?? 'empty group')
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
clone.config = children
|
|
208
|
+
}
|
|
209
|
+
if (clone.id === 'persona') appendPersonaSuffixObject(clone)
|
|
210
|
+
if (sourceDir !== undefined && typeof clone.name === 'string' && clone.name.startsWith('./')) {
|
|
211
|
+
// The loader imports row names as URLs / bare specifiers; a relative
|
|
212
|
+
// row resolves to a file:// URL spelling of its absolute location.
|
|
213
|
+
clone.name = pathToFileURL(join(sourceDir, clone.name.slice(2))).href
|
|
214
|
+
}
|
|
215
|
+
return clone
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const row of basePlugins) {
|
|
219
|
+
const pruned = prune(row)
|
|
220
|
+
if (pruned !== null) kept.push(pruned)
|
|
221
|
+
}
|
|
222
|
+
kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }))
|
|
223
|
+
return { plugins: kept, removed }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The sentence appended to the preset's persona.
|
|
228
|
+
*
|
|
229
|
+
* A session's recorded cwd is the UNC spelling (`\\wsl.localhost\<distro>\…`)
|
|
230
|
+
* because that is the only form the Windows-side harness accepts, while the
|
|
231
|
+
* shell in that session reports a Linux path. Without this the model is told one
|
|
232
|
+
* spelling and observes another.
|
|
233
|
+
*/
|
|
234
|
+
export const WSL_PERSONA_SENTENCE = 'Paths under \\\\wsl.localhost\\<distro> are Windows spellings of directories inside that WSL distribution — \\\\wsl.localhost\\<distro>\\home\\me is /home/me there. Every command runs in the distribution, so use Linux paths, and reach Windows files as /mnt/<drive>/…'
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Append the WSL sentence to a persona row's text field.
|
|
238
|
+
*
|
|
239
|
+
* Handles the three shapes a preset may use: an inline `suffix`, a block-scalar
|
|
240
|
+
* `suffix`, and a persona that carries only `text`. A row with none of them
|
|
241
|
+
* gains a `suffix` under `config:`.
|
|
242
|
+
* @param {{ id: string | null, lines: string[] }} block - the persona block.
|
|
243
|
+
* @returns {boolean} whether the block was amended.
|
|
244
|
+
*/
|
|
245
|
+
export function appendPersonaSuffix(block) {
|
|
246
|
+
const lines = block.lines
|
|
247
|
+
const configIndex = lines.findIndex((line) => /^(\s*)config:\s*$/.test(line))
|
|
248
|
+
if (configIndex === -1) return false
|
|
249
|
+
const configIndent = (/^(\s*)config:\s*$/.exec(lines[configIndex])?.[1] ?? '').length
|
|
250
|
+
const childIndent = configIndent + 2
|
|
251
|
+
const child = new RegExp(`^ {${childIndent}}(suffix|text|prefix):(.*)$`)
|
|
252
|
+
for (const field of ['suffix', 'text', 'prefix']) {
|
|
253
|
+
const index = lines.findIndex((line, at) => at > configIndex && child.test(line) && new RegExp(`^ {${childIndent}}${field}:`).test(line))
|
|
254
|
+
if (index === -1) continue
|
|
255
|
+
const value = (new RegExp(`^ {${childIndent}}${field}:(.*)$`).exec(lines[index])?.[1] ?? '').trim()
|
|
256
|
+
// A block scalar (`>-`, `|`, `|-`, …) continues on the following lines.
|
|
257
|
+
if (value === '' || /^[|>][-+]?\d*$/.test(value)) {
|
|
258
|
+
lines.splice(index + 1, 0, `${' '.repeat(childIndent + 2)}${WSL_PERSONA_SENTENCE}`)
|
|
259
|
+
} else if ((value.startsWith("'") && value.endsWith("'") && value.length >= 2)
|
|
260
|
+
|| (value.startsWith('"') && value.endsWith('"') && value.length >= 2)) {
|
|
261
|
+
// A quoted scalar must grow INSIDE its quotes; appending after a closing
|
|
262
|
+
// quote would produce invalid YAML.
|
|
263
|
+
const quote = value[0]
|
|
264
|
+
const sentence = quote === "'" ? WSL_PERSONA_SENTENCE.replace(/'/g, "''") : WSL_PERSONA_SENTENCE
|
|
265
|
+
lines[index] = `${lines[index].slice(0, -1)} ${sentence}${quote}`
|
|
266
|
+
} else {
|
|
267
|
+
lines[index] = `${lines[index]} ${WSL_PERSONA_SENTENCE}`
|
|
268
|
+
}
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
// No text field of its own: gain one as the first key under `config:`.
|
|
272
|
+
lines.splice(configIndex + 1, 0, `${' '.repeat(childIndent)}suffix: ${WSL_PERSONA_SENTENCE}`)
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Quote one scalar for a YAML single-quoted value.
|
|
278
|
+
* @param {string} value - the raw value.
|
|
279
|
+
* @returns {string} the quoted scalar.
|
|
280
|
+
*/
|
|
281
|
+
function quote(value) {
|
|
282
|
+
return `'${value.replace(/'/g, "''")}'`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Split an entry list into top-level blocks.
|
|
287
|
+
* @param {string} source - the loader entry list.
|
|
288
|
+
* @returns {Array<{ id: string | null, lines: string[] }>} the blocks in order.
|
|
289
|
+
*/
|
|
290
|
+
export function splitBlocks(source) {
|
|
291
|
+
const blocks = []
|
|
292
|
+
let current = { id: null, lines: [] }
|
|
293
|
+
for (const line of source.split('\n')) {
|
|
294
|
+
const match = /^- id:\s*(.+?)\s*$/.exec(line)
|
|
295
|
+
if (match !== null) {
|
|
296
|
+
if (current.lines.length > 0 || current.id !== null) blocks.push(current)
|
|
297
|
+
// Normalise the captured id so every YAML spelling of the same row
|
|
298
|
+
// resolves to one canonical id for world-row removal: strip trailing
|
|
299
|
+
// comments, YAML anchors (`&a name`), and surrounding quotes.
|
|
300
|
+
let id = match[1].replace(/\s+#.*$/, '').trim()
|
|
301
|
+
if (id.startsWith('&')) id = id.replace(/^&\S+\s+/, '')
|
|
302
|
+
id = id.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
|
|
303
|
+
current = { id, lines: [line] }
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
current.lines.push(line)
|
|
307
|
+
}
|
|
308
|
+
blocks.push(current)
|
|
309
|
+
return blocks
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Build the `wsl-world` group that carries the WSL execution world.
|
|
314
|
+
*
|
|
315
|
+
* The group isolates every service a row inside it publishes, so one session
|
|
316
|
+
* can run in a distribution while the process keeps serving Windows sessions.
|
|
317
|
+
* `subprocess-wsl` precedes the consumers that inject it: the shell executor
|
|
318
|
+
* hands it a Linux argv and lets the provider own the `wsl.exe` wrapper.
|
|
319
|
+
* @param {object} options - generation options.
|
|
320
|
+
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
321
|
+
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
322
|
+
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
323
|
+
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
324
|
+
* @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
|
|
325
|
+
* @returns {string[]} the group's YAML lines.
|
|
326
|
+
*/
|
|
327
|
+
function worldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor }) {
|
|
328
|
+
const distroConfig = distro === undefined ? [] : [' config:', ` distro: ${quote(distro)}`]
|
|
329
|
+
return [
|
|
330
|
+
'- id: wsl-world',
|
|
331
|
+
' name: cordis:group',
|
|
332
|
+
' group: true',
|
|
333
|
+
' isolate:',
|
|
334
|
+
' shell: true',
|
|
335
|
+
' fs: true',
|
|
336
|
+
' subprocess: true',
|
|
337
|
+
' config:',
|
|
338
|
+
' - id: subprocess-wsl',
|
|
339
|
+
` name: ${quote(subprocessPath)}`,
|
|
340
|
+
...distroConfig,
|
|
341
|
+
' - id: shell-wsl',
|
|
342
|
+
` name: ${quote(shellPath)}`,
|
|
343
|
+
...distroConfig,
|
|
344
|
+
' - id: fs-wsl',
|
|
345
|
+
` name: ${quote(fsPath)}`,
|
|
346
|
+
...distroConfig,
|
|
347
|
+
// No terminal registry row: the Web terminal controller spawns through
|
|
348
|
+
// `agent.ctx.get('subprocess').spawnTerminal(...)`
|
|
349
|
+
// (packages/api/terminal-controller/src/index.ts:333,346), so the realm's
|
|
350
|
+
// subprocess provider already puts the PTY inside the distribution. A
|
|
351
|
+
// `terminals` realm would only serve the model's persistent-shell tool,
|
|
352
|
+
// which this preset does not mount.
|
|
353
|
+
' - id: tool-bash',
|
|
354
|
+
" name: '@deepseek-ai/dsh-tool-bash'",
|
|
355
|
+
' - id: tool-fs',
|
|
356
|
+
" name: '@deepseek-ai/dsh-tool-fs'",
|
|
357
|
+
...includeEditor
|
|
358
|
+
? [' - id: str-replace-editor', " name: '@deepseek-ai/dsh-tool-str-replace-editor'"]
|
|
359
|
+
: [],
|
|
360
|
+
]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Rewrite relative row names so they still resolve from the variant directory.
|
|
365
|
+
*
|
|
366
|
+
* A preset may name a row by a path relative to its own directory
|
|
367
|
+
* (`./tool-bootstrap.mjs`). The variant lives in a different directory, so the
|
|
368
|
+
* relative spelling would resolve against the wrong base and the preset would
|
|
369
|
+
* fail to mount.
|
|
370
|
+
* @param {{ lines: string[] }} block - one top-level entry.
|
|
371
|
+
* @param {string} sourceDir - directory of the preset being derived from.
|
|
372
|
+
* @returns {number} how many names were rewritten.
|
|
373
|
+
*/
|
|
374
|
+
export function rewriteRelativeNames(block, sourceDir) {
|
|
375
|
+
let rewritten = 0
|
|
376
|
+
block.lines = block.lines.map((line) => {
|
|
377
|
+
const match = /^(\s*)name:\s*['"]?\.\/([^'"]+)['"]?\s*$/.exec(line)
|
|
378
|
+
if (match === null) return line
|
|
379
|
+
const absolute = join(sourceDir, match[2]).replace(/\\/g, '/')
|
|
380
|
+
rewritten += 1
|
|
381
|
+
return `${match[1]}name: '${absolute}'`
|
|
382
|
+
})
|
|
383
|
+
return rewritten
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Rewrite one shipped preset into its WSL variant.
|
|
388
|
+
*
|
|
389
|
+
* The whole preset is wrapped in the realm rather than sitting beside it. A
|
|
390
|
+
* sibling group would leave every consumer outside it — a persistent-shell
|
|
391
|
+
* group, a hand-written tool row — resolving the host's providers, which is the
|
|
392
|
+
* Windows execution world; wrapping makes the realm the preset's own scope, so
|
|
393
|
+
* every row resolves the WSL providers. Rows that hardcode Windows tooling are
|
|
394
|
+
* dropped and re-mounted in their WSL form.
|
|
395
|
+
* @param {string} source - the shipped preset's `agent.cordis.yml` text.
|
|
396
|
+
* @param {object} options - generation options.
|
|
397
|
+
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
398
|
+
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
399
|
+
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
400
|
+
* @param {string} [options.distro] - distribution for paths that do not name one.
|
|
401
|
+
* @param {string} [options.sourceDir] - directory the source preset lives in.
|
|
402
|
+
* @returns {{ yaml: string, removed: string[], added: boolean }} the rewritten preset and what changed.
|
|
403
|
+
*/
|
|
404
|
+
export function renderWslPreset(source, options) {
|
|
405
|
+
const blocks = splitBlocks(source)
|
|
406
|
+
const kept = []
|
|
407
|
+
const removed = []
|
|
408
|
+
let includeEditor = false
|
|
409
|
+
let personaAmended = false
|
|
410
|
+
for (const block of blocks) {
|
|
411
|
+
if (block.id !== null && WORLD_ROWS.has(block.id)) {
|
|
412
|
+
removed.push(block.id)
|
|
413
|
+
if (block.id === 'str-replace-editor') includeEditor = true
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
if (block.id === 'persona') personaAmended = appendPersonaSuffix(block)
|
|
417
|
+
if (options.sourceDir !== undefined) rewriteRelativeNames(block, options.sourceDir)
|
|
418
|
+
kept.push(block)
|
|
419
|
+
}
|
|
420
|
+
// `split('\n')` consumed the separators, so blocks rejoin with one newline;
|
|
421
|
+
// joining with '' would glue the next `- id:` onto the previous line.
|
|
422
|
+
const body = kept.map((block) => block.lines.join('\n')).join('\n')
|
|
423
|
+
// One level down: the original entry list becomes the group's `config` list.
|
|
424
|
+
const nested = body
|
|
425
|
+
.split('\n')
|
|
426
|
+
.map((line) => (line.trim() === '' ? line : ` ${line}`))
|
|
427
|
+
.join('\n')
|
|
428
|
+
const group = worldGroup({
|
|
429
|
+
subprocessPath: options.subprocessPath,
|
|
430
|
+
shellPath: options.shellPath,
|
|
431
|
+
fsPath: options.fsPath,
|
|
432
|
+
distro: options.distro,
|
|
433
|
+
includeEditor,
|
|
434
|
+
})
|
|
435
|
+
return {
|
|
436
|
+
yaml: `${group.join('\n')}\n${nested}\n`,
|
|
437
|
+
removed,
|
|
438
|
+
added: true,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Render the preset's display metadata.
|
|
444
|
+
* @param {{ name: string, description: string }} metadata - display name and description.
|
|
445
|
+
* @returns {string} the `preset.yml` text.
|
|
446
|
+
*/
|
|
447
|
+
export function renderPresetMetadata(metadata) {
|
|
448
|
+
// Quote only when the value contains characters that YAML would
|
|
449
|
+
// misinterpret; plain alphanumeric + CJK + basic punctuation stays unquoted.
|
|
450
|
+
const scalar = (value) => {
|
|
451
|
+
const s = String(value)
|
|
452
|
+
if (/^[\w.\-\u4e00-\u9fff\u3000-\u303f\u00b7(): ]+$/u.test(s) && !/^\s|\s$/.test(s) && !s.includes(': ') && !s.startsWith('#')) return s
|
|
453
|
+
return `'${s.replace(/'/g, "''")}'`
|
|
454
|
+
}
|
|
455
|
+
return `name: ${scalar(metadata.name)}\ndescription: ${scalar(metadata.description)}\n`
|
|
456
|
+
}
|