dsh-wsl-workspace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fs.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * WSL Service Provider for the `ctx.fs` capability seam. Backed by the host
3
+ * filesystem over the `\\wsl.localhost\<distro>\…` 9P share — zero install
4
+ * inside the distribution — while every model/UI-facing path is the Linux
5
+ * path a WSL process would open (`processPath`, `displayPath`, `fileUrl`).
6
+ * Reuses `LocalFileSystem`'s mechanics (realpath identity, atomic writes,
7
+ * per-target locks, version guards) unchanged, because those operate on the
8
+ * UNC path Node can open directly.
9
+ *
10
+ * Both UNC paths and Linux absolute paths resolve; Windows drive paths
11
+ * resolve through their `/mnt/<drive>` form, so a WSL-composed session can
12
+ * still touch the Windows filesystem coherently.
13
+ * @module dsh-wsl-workspace/fs
14
+ */
15
+
16
+ import { Context } from '@deepseek-ai/cordis'
17
+ import z from '@deepseek-ai/schemastery'
18
+ import { link, lstat, rename } from 'node:fs/promises'
19
+ import { FsError } from '@deepseek-ai/dsh-fs'
20
+ import type { FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
21
+ import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
22
+ import {
23
+ isAbsoluteLinuxPath,
24
+ joinUnc,
25
+ mntToWindowsPath,
26
+ parseWslUnc,
27
+ windowsToMntPath,
28
+ } from './shared/paths.ts'
29
+
30
+ /** Plugin config. `cwd`/`distro` are optional because UNC workdirs carry both. */
31
+ export interface Config {
32
+ /** Base directory for relative paths without a per-call cwd (UNC or Linux). */
33
+ cwd?: string
34
+ /** Default distribution for Linux-absolute paths without a UNC cwd. */
35
+ distro?: string
36
+ /** Exclusive UTF-8 byte limit on each overwrite-diff side (see fs-local). */
37
+ diffBasisMaxBytes?: number
38
+ }
39
+
40
+ /** One translated coordinate: the input the local backend opens plus its cwd. */
41
+ interface Translated {
42
+ /** Absolute path to hand to the local backend (UNC or Windows drive). */
43
+ input: string
44
+ /** Absolute Windows-side base for relative inputs (UNC or Windows drive). */
45
+ cwd: string
46
+ }
47
+
48
+ /**
49
+ * The WSL filesystem backend. Identity keys are canonical UNC paths; the
50
+ * Linux form is derived on demand, so both worlds stay in sync across
51
+ * aliases and symlinks.
52
+ */
53
+ export class WslFileSystem extends LocalFileSystem {
54
+ static override Config: z<Config> = z.object({
55
+ cwd: z.string(),
56
+ distro: z.string(),
57
+ diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
58
+ })
59
+
60
+ private readonly distro: string | undefined
61
+
62
+ constructor(ctx: Context, config: Config) {
63
+ // schemastery fills the defaults before construction; the parent validates
64
+ // `diffBasisMaxBytes` and stores the resolved shape.
65
+ super(ctx, config)
66
+ this.distro = config.distro
67
+ // The 9P/drvfs substrate has no hard links and no Win32 security semantics:
68
+ // replace the atomic-publication boundaries the parent's fsio defaults to.
69
+ this.internals = {
70
+ linkFile: WslFileSystem.publishNoReplace,
71
+ replaceFile: WslFileSystem.replaceOverWrite,
72
+ copyFileDacl: WslFileSystem.skipDaclCopy,
73
+ }
74
+ }
75
+
76
+ /**
77
+ * No-replace publication for filesystems without hard links. A real
78
+ * collision (a concurrent external creator won) must still surface as the
79
+ * original EEXIST so the guarded-create failure path classifies it; an
80
+ * absent target falls back to rename, which on Windows publishes without
81
+ * replacing anything. Safe against this backend's own writers because the
82
+ * per-target lock serializes them.
83
+ * @param tempPath - the staged file.
84
+ * @param destPath - the destination to create.
85
+ */
86
+ private static async publishNoReplace(tempPath: string, destPath: string): Promise<void> {
87
+ try {
88
+ await link(tempPath, destPath)
89
+ return
90
+ } catch (error) {
91
+ let exists = false
92
+ try {
93
+ await lstat(destPath)
94
+ exists = true
95
+ } catch {
96
+ // Absent destination: rename publishes the staged file.
97
+ }
98
+ if (exists) throw error
99
+ await rename(tempPath, destPath)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Security-preserving replacement boundary: Windows rename replaces an
105
+ * existing destination atomically; no DACL preservation is needed over 9P.
106
+ * @param destPath - the file being replaced.
107
+ * @param tempPath - the staged replacement.
108
+ */
109
+ private static async replaceOverWrite(destPath: string, tempPath: string): Promise<void> {
110
+ await rename(tempPath, destPath)
111
+ }
112
+
113
+ /** 9P files inherit their directory's DACL; nothing to preserve. */
114
+ private static async skipDaclCopy(): Promise<void> {}
115
+
116
+ /** Translate a model/plugin path into Windows-side coordinates. */
117
+ private translate(path: string, cwd?: string): Translated {
118
+ const unc = parseWslUnc(path)
119
+ if (unc !== null) {
120
+ return { input: joinUnc(unc.distro, unc.linuxPath), cwd: this.cwdOr(cwd) }
121
+ }
122
+ if (isAbsoluteLinuxPath(path)) {
123
+ // /mnt/<drive>/… names the Windows filesystem inside the Linux world
124
+ // (the dual-access path for migration): open the drive path directly so
125
+ // both worlds stay coherent — the display stays the /mnt form.
126
+ const win = mntToWindowsPath(path)
127
+ if (win !== null) return { input: win, cwd: this.cwdOr(cwd) }
128
+ return { input: joinUnc(this.distroFor(cwd), path), cwd: this.cwdOr(cwd) }
129
+ }
130
+ if (windowsToMntPath(path) !== null) {
131
+ // Windows drive paths open directly; the Linux world reaches them via /mnt.
132
+ return { input: path, cwd: this.cwdOr(cwd) }
133
+ }
134
+ // Relative: resolve against the caller cwd (or the configured base).
135
+ const base = this.uncCwd(cwd)
136
+ return { input: path, cwd: base }
137
+ }
138
+
139
+ /** A base for absolute inputs (unused by resolution, but the parent needs one). */
140
+ private cwdOr(cwd?: string): string {
141
+ return cwd ?? this.config.cwd ?? process.cwd()
142
+ }
143
+
144
+ private uncCwd(cwd?: string): string {
145
+ const base = cwd ?? this.config.cwd
146
+ if (base === undefined || base === '') {
147
+ throw new FsError('wsl-fs: no cwd and no configured base for relative resolution', 'FS_IO_ERROR')
148
+ }
149
+ const unc = parseWslUnc(base)
150
+ if (unc !== null) return joinUnc(unc.distro, unc.linuxPath)
151
+ if (isAbsoluteLinuxPath(base)) return joinUnc(this.distroFor(base), base)
152
+ if (windowsToMntPath(base) !== null) return base
153
+ throw new FsError(`wsl-fs: cwd "${base}" is not in the WSL execution world`, 'FS_IO_ERROR')
154
+ }
155
+
156
+ private distroFor(cwd?: string): string {
157
+ const fromCwd = parseWslUnc(cwd ?? '')
158
+ if (fromCwd !== null) return fromCwd.distro
159
+ const distro = this.distro
160
+ if (distro === undefined || distro === '') {
161
+ throw new FsError('wsl-fs: Linux path carries no distribution and none is configured', 'FS_IO_ERROR')
162
+ }
163
+ return distro
164
+ }
165
+
166
+ /** The Linux display path for a resolved Windows-side path. */
167
+ private linuxDisplay(raw: string): string {
168
+ const unc = parseWslUnc(raw)
169
+ if (unc !== null) return unc.linuxPath
170
+ const mnt = windowsToMntPath(raw)
171
+ if (mnt !== null) return mnt
172
+ throw new FsError(`wsl-fs: resolved path "${raw}" is outside the WSL execution world`, 'FS_IO_ERROR')
173
+ }
174
+
175
+ override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
176
+ if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
177
+ const { input, cwd } = this.translate(path, opts?.cwd)
178
+ const local = await super.resolve(input, {
179
+ cwd,
180
+ ...opts?.signal !== undefined ? { signal: opts.signal } : {},
181
+ })
182
+ return { targetKey: local.targetKey, displayPath: this.linuxDisplay(String(local.displayPath)) }
183
+ }
184
+
185
+ override processPath(target: FsTarget): string {
186
+ const key = String(target.targetKey)
187
+ const unc = parseWslUnc(key)
188
+ if (unc !== null) return unc.linuxPath
189
+ const mnt = windowsToMntPath(key)
190
+ if (mnt !== null) return mnt
191
+ throw new FsError(`wsl-fs: target "${target.displayPath}" is outside the WSL execution world`, 'FS_IO_ERROR')
192
+ }
193
+
194
+ override fileUrl(target: FsTarget): string {
195
+ const linux = this.processPath(target)
196
+ const encoded = linux.split('/').map(encodeURIComponent).join('/')
197
+ return `file://${encoded}`
198
+ }
199
+
200
+ override contains(parent: FsTarget, child: FsTarget): boolean {
201
+ const parentWorld = this.worldPath(parent)
202
+ const childWorld = this.worldPath(child)
203
+ if (parentWorld.distro !== childWorld.distro) return false
204
+ const parentPath = parentWorld.linuxPath
205
+ const childPath = childWorld.linuxPath
206
+ if (childPath === parentPath) return true
207
+ return parentPath === '/' ? true : childPath.startsWith(`${parentPath}/`)
208
+ }
209
+
210
+ /** One target's (distro, linuxPath) pair for containment; `undefined` distro = Windows world. */
211
+ private worldPath(target: FsTarget): { distro: string | undefined; linuxPath: string } {
212
+ const key = String(target.targetKey)
213
+ const unc = parseWslUnc(key)
214
+ if (unc !== null) return { distro: unc.distro, linuxPath: unc.linuxPath }
215
+ const mnt = windowsToMntPath(key)
216
+ if (mnt !== null) return { distro: undefined, linuxPath: mnt }
217
+ throw new FsError(`wsl-fs: target "${target.displayPath}" is outside the WSL execution world`, 'FS_IO_ERROR')
218
+ }
219
+
220
+ override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
221
+ if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
222
+ if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
223
+ const { input, cwd } = this.translate(path, opts?.cwd)
224
+ return super.lstat(input, { cwd }, signal)
225
+ }
226
+ }
227
+
228
+ export default WslFileSystem
@@ -0,0 +1,199 @@
1
+ /**
2
+ * WSL preset-variant generator. For every healthy source preset the roster
3
+ * supplies, a `wsl-<id>` variant is materialized under the roster's user
4
+ * root: the source composition with its shell/filesystem world replaced by
5
+ * the WSL providers, so any mode (standard, minimal, code, cordis, user
6
+ * presets) can run on top of a WSL execution world. The execution world is
7
+ * therefore orthogonal to the mode instead of a mode itself.
8
+ *
9
+ * The transformation is text-level on the top-level rows of the composition
10
+ * (the shape all shipped presets share), with surgical edits for the known
11
+ * special groups; unknown shapes are kept verbatim where possible.
12
+ * @module dsh-wsl-workspace/host/variants
13
+ */
14
+
15
+ /** Top-level rows that name the execution world and are replaced by the variant's own. */
16
+ const WORLD_ROWS = new Set(['tool-bash', 'tool-pwsh', 'tool-fs', 'tool-fs-search', 'filesystem', 'persistent-shell'])
17
+
18
+ /** The injected WSL world group: providers + the bash/fs consumers, entry-local. */
19
+ function wslWorldGroup(shellPath: string, fsPath: string, includeEditor: boolean): string {
20
+ return [
21
+ '# ── WSL execution world (dsh-wsl-workspace variant) ─────────────────────',
22
+ '# The shell and fs services are provided entry-locally (the isolate',
23
+ '# realm); host services (tools registry, shell-env, jobs) fall through.',
24
+ '# tool-fs-search is intentionally absent: the packaged ripgrep runs on',
25
+ '# the Windows host and cannot open Linux paths; WSL sessions search with',
26
+ '# shell tools instead.',
27
+ '- id: wsl-world',
28
+ " name: cordis:group",
29
+ ' group: true',
30
+ ' isolate:',
31
+ ' shell: true',
32
+ ' fs: true',
33
+ ' config:',
34
+ ` - id: shell-wsl`,
35
+ ` name: '${shellPath.replace(/'/g, "''")}'`,
36
+ ' - id: fs-wsl',
37
+ ` name: '${fsPath.replace(/'/g, "''")}'`,
38
+ ' - id: tool-bash',
39
+ " name: '@deepseek-ai/dsh-tool-bash'",
40
+ ' - id: tool-fs',
41
+ " name: '@deepseek-ai/dsh-tool-fs'",
42
+ ...(includeEditor
43
+ ? [
44
+ ' - id: str-replace-editor',
45
+ " name: '@deepseek-ai/dsh-tool-str-replace-editor'",
46
+ ' config:',
47
+ ' maxOutputChars: 16000',
48
+ ]
49
+ : []),
50
+ '',
51
+ ].join('\n')
52
+ }
53
+
54
+ /**
55
+ * The persistent-shell group re-pointed at WSL: the PTY spawns wsl.exe's
56
+ * bash instead of a host `bash` (which does not exist on Windows).
57
+ */
58
+ function persistentShellGroup(): string {
59
+ return [
60
+ '# ── persistent shell over WSL (variant) ─────────────────────────────────',
61
+ '- id: persistent-shell',
62
+ ' name: cordis:group',
63
+ ' group: true',
64
+ ' isolate:',
65
+ ' terminals: true',
66
+ ' config:',
67
+ ' - id: pty',
68
+ " name: '@deepseek-ai/dsh-terminal'",
69
+ '',
70
+ ' - id: terminal-bash',
71
+ " name: '@deepseek-ai/dsh-terminal-bash'",
72
+ ' config:',
73
+ ' timeoutMs: 300000',
74
+ " shellPath: 'wsl.exe'",
75
+ " shellArgs: ['-e', 'bash', '-l']",
76
+ '',
77
+ ' - id: persistent-bash',
78
+ " name: '@deepseek-ai/dsh-tool-bash-persistent'",
79
+ ' config:',
80
+ ' timeoutMs: 300000',
81
+ ' description: |-',
82
+ ' Run commands in a bash shell inside the WSL distribution',
83
+ ' * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
84
+ " * You don't have access to the internet via this tool.",
85
+ ' * You do have access to a mirror of common linux and python packages via apt and pip.',
86
+ ' * State is persistent across command calls and discussions with the user.',
87
+ " * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
88
+ ' * Please avoid commands that may produce a very large amount of output.',
89
+ " * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.",
90
+ '',
91
+ ].join('\n')
92
+ }
93
+
94
+ /** The sentence appended to a standard-like persona when the variant runs in WSL. */
95
+ const PERSONA_APPEND = ' Your working directory {{cwd}} is inside a WSL (Windows Subsystem for Linux) distribution: the bash tool and the file read/write/edit tools use Linux paths, and the Windows filesystem is reachable as /mnt/<drive> for file migration.'
96
+
97
+ /** The top-level rows of one composition, as (startLine, endLineExclusive) spans. */
98
+ function topLevelSpans(lines: readonly string[]): { start: number; end: number }[] {
99
+ const spans: { start: number; end: number }[] = []
100
+ let start = -1
101
+ for (let index = 0; index < lines.length; index++) {
102
+ if (lines[index]?.startsWith('- id: ') === true) {
103
+ if (start >= 0) spans.push({ start, end: index })
104
+ start = index
105
+ }
106
+ }
107
+ if (start >= 0) spans.push({ start, end: lines.length })
108
+ return spans
109
+ }
110
+
111
+ /** The row id of a top-level span, or undefined when the first line is malformed. */
112
+ function spanId(lines: readonly string[], span: { start: number; end: number }): string | undefined {
113
+ return /^- id: ([A-Za-z0-9_.-]+)/.exec(lines[span.start] ?? '')?.[1]
114
+ }
115
+
116
+ /** Whether a top-level span is a `persona` row with an appendable folded text. */
117
+ function appendablePersona(lines: readonly string[], span: { start: number; end: number }): boolean {
118
+ const block = lines.slice(span.start, span.end).join('\n')
119
+ if (!block.includes('complete: true') && /text: [>|-]/.test(block)) {
120
+ // Append only when the folded text actually has content lines.
121
+ const textLine = block.split('\n').find(line => /^(\s*)text: [>|-]/.test(line))
122
+ if (textLine !== undefined) {
123
+ const indent = /^(\s*)/.exec(textLine)?.[1]?.length ?? 0
124
+ return block.split('\n').some(line => line.length > indent && /^\s+/.test(line) && !line.includes(':'))
125
+ }
126
+ }
127
+ return false
128
+ }
129
+
130
+ /** Append the WSL sentence to a persona row's folded text (in place of its last text line). */
131
+ function appendPersona(lines: readonly string[], span: { start: number; end: number }): string[] {
132
+ const block = lines.slice(span.start, span.end)
133
+ const textIndex = block.findIndex(line => /^(\s*)text: [>|-]/.test(line))
134
+ if (textIndex < 0) return [...block]
135
+ const indent = /^(\s*)/.exec(block[textIndex] ?? '')?.[1]?.length ?? 0
136
+ let lastText = -1
137
+ for (let index = textIndex + 1; index < block.length; index++) {
138
+ const line = block[index] ?? ''
139
+ if (line.trim() === '') continue
140
+ if (line.length > indent && /^\s+/.test(line)) lastText = index
141
+ }
142
+ if (lastText < 0) return [...block]
143
+ const updated = [...block]
144
+ const textIndent = /^(\s*)/.exec(block[lastText] ?? '')?.[1] ?? ' '
145
+ updated.splice(lastText + 1, 0, `${textIndent}${PERSONA_APPEND}`)
146
+ return updated
147
+ }
148
+
149
+ /**
150
+ * Transform one source preset composition into its WSL variant: drop the
151
+ * execution-world rows, keep everything else verbatim, and append the WSL
152
+ * world group (plus the persistent-shell group when the source had one).
153
+ * @param source - the source composition text.
154
+ * @param shellPath - absolute path of the plugin's built WSL shell provider.
155
+ * @param fsPath - absolute path of the plugin's built WSL fs provider.
156
+ * @returns the variant composition text.
157
+ */
158
+ export function transformPresetForWsl(source: string, shellPath: string, fsPath: string): string {
159
+ const lines = source.split('\n')
160
+ const spans = topLevelSpans(lines)
161
+ const kept: string[] = []
162
+ let sawEditor = false
163
+ let sawPersistent = false
164
+ let personaAppended = false
165
+ for (const span of spans) {
166
+ const id = spanId(lines, span)
167
+ if (id === undefined) {
168
+ kept.push(...lines.slice(span.start, span.end))
169
+ continue
170
+ }
171
+ if (WORLD_ROWS.has(id)) {
172
+ if (id === 'persistent-shell') sawPersistent = true
173
+ continue
174
+ }
175
+ if (id === 'persona' && !personaAppended && appendablePersona(lines, span)) {
176
+ kept.push(...appendPersona(lines, span))
177
+ personaAppended = true
178
+ continue
179
+ }
180
+ kept.push(...lines.slice(span.start, span.end))
181
+ if (id === 'str-replace-editor') sawEditor = true
182
+ }
183
+ if (source.includes('str-replace-editor')) sawEditor = true
184
+ const result = [...kept]
185
+ if (result.length > 0 && result[result.length - 1] !== '') result.push('')
186
+ result.push(wslWorldGroup(shellPath, fsPath, sawEditor))
187
+ if (sawPersistent) result.push(persistentShellGroup())
188
+ return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '\n')
189
+ }
190
+
191
+ /** Whether an id is one of this plugin's own preset directories. */
192
+ export function isWslVariantId(id: string): boolean {
193
+ return id === 'wsl' || /^wsl-[a-z0-9-]+$/.test(id)
194
+ }
195
+
196
+ /** The variant id for one source preset id. */
197
+ export function variantIdFor(sourceId: string): string {
198
+ return `wsl-${sourceId.toLowerCase()}`
199
+ }