dsh-wsl-workspace 0.2.2 → 0.3.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 CHANGED
@@ -1,228 +1,247 @@
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
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 { AsyncLocalStorage } from 'node:async_hooks'
19
+ import { link, lstat, rename } from 'node:fs/promises'
20
+ import { FsError } from '@deepseek-ai/dsh-fs'
21
+ import type { FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
22
+ import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
23
+ import {
24
+ isAbsoluteLinuxPath,
25
+ joinUnc,
26
+ mntToWindowsPath,
27
+ parseWslUnc,
28
+ windowsToMntPath,
29
+ } from './shared/paths.ts'
30
+ import { defaultDistroSync } from './shared/wsl.ts'
31
+
32
+ /** Plugin config. `cwd`/`distro` are optional because UNC workdirs carry both. */
33
+ export interface Config {
34
+ /** Base directory for relative paths without a per-call cwd (UNC or Linux). */
35
+ cwd?: string
36
+ /** Default distribution for Linux-absolute paths without a UNC cwd. */
37
+ distro?: string
38
+ /** Exclusive UTF-8 byte limit on each overwrite-diff side (see fs-local). */
39
+ diffBasisMaxBytes?: number
40
+ }
41
+
42
+ /** One translated coordinate: the input the local backend opens plus its cwd. */
43
+ interface Translated {
44
+ /** Absolute path to hand to the local backend (UNC or Windows drive). */
45
+ input: string
46
+ /** Absolute Windows-side base for relative inputs (UNC or Windows drive). */
47
+ cwd: string
48
+ }
49
+
50
+ interface ToolExecution {
51
+ agent?: { session: { header: { cwd?: string } } }
52
+ }
53
+
54
+ /**
55
+ * The WSL filesystem backend. Identity keys are canonical UNC paths; the
56
+ * Linux form is derived on demand, so both worlds stay in sync across
57
+ * aliases and symlinks.
58
+ */
59
+ export class WslFileSystem extends LocalFileSystem {
60
+ static override Config: z<Config> = z.object({
61
+ cwd: z.string(),
62
+ distro: z.string(),
63
+ diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
64
+ })
65
+
66
+ private readonly distro: string | undefined
67
+ private readonly executionCwd = new AsyncLocalStorage<string | undefined>()
68
+
69
+ constructor(ctx: Context, config: Config) {
70
+ // schemastery fills the defaults before construction; the parent validates
71
+ // `diffBasisMaxBytes` and stores the resolved shape.
72
+ super(ctx, config)
73
+ this.distro = config.distro
74
+ ctx.on('tools/execute', (exec: ToolExecution, next: () => unknown) => (
75
+ this.executionCwd.run(exec.agent?.session.header.cwd, next)
76
+ ))
77
+ // The 9P/drvfs substrate has no hard links and no Win32 security semantics:
78
+ // replace the atomic-publication boundaries the parent's fsio defaults to.
79
+ this.internals = {
80
+ linkFile: WslFileSystem.publishNoReplace,
81
+ replaceFile: WslFileSystem.replaceOverWrite,
82
+ copyFileDacl: WslFileSystem.skipDaclCopy,
83
+ }
84
+ }
85
+
86
+ /**
87
+ * No-replace publication for filesystems without hard links. A real
88
+ * collision (a concurrent external creator won) must still surface as the
89
+ * original EEXIST so the guarded-create failure path classifies it; an
90
+ * absent target falls back to rename, which on Windows publishes without
91
+ * replacing anything. Safe against this backend's own writers because the
92
+ * per-target lock serializes them.
93
+ * @param tempPath - the staged file.
94
+ * @param destPath - the destination to create.
95
+ */
96
+ private static async publishNoReplace(tempPath: string, destPath: string): Promise<void> {
97
+ try {
98
+ await link(tempPath, destPath)
99
+ return
100
+ } catch (error) {
101
+ let exists = false
102
+ try {
103
+ await lstat(destPath)
104
+ exists = true
105
+ } catch {
106
+ // Absent destination: rename publishes the staged file.
107
+ }
108
+ if (exists) throw error
109
+ await rename(tempPath, destPath)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Security-preserving replacement boundary: Windows rename replaces an
115
+ * existing destination atomically; no DACL preservation is needed over 9P.
116
+ * @param destPath - the file being replaced.
117
+ * @param tempPath - the staged replacement.
118
+ */
119
+ private static async replaceOverWrite(destPath: string, tempPath: string): Promise<void> {
120
+ await rename(tempPath, destPath)
121
+ }
122
+
123
+ /** 9P files inherit their directory's DACL; nothing to preserve. */
124
+ private static async skipDaclCopy(): Promise<void> {}
125
+
126
+ /** Translate a model/plugin path into Windows-side coordinates. */
127
+ private translate(path: string, cwd?: string): Translated {
128
+ const unc = parseWslUnc(path)
129
+ if (unc !== null) {
130
+ return { input: joinUnc(unc.distro, unc.linuxPath), cwd: this.cwdOr(cwd) }
131
+ }
132
+ if (isAbsoluteLinuxPath(path)) {
133
+ // /mnt/<drive>/… names the Windows filesystem inside the Linux world
134
+ // (the dual-access path for migration): open the drive path directly so
135
+ // both worlds stay coherent — the display stays the /mnt form.
136
+ const win = mntToWindowsPath(path)
137
+ if (win !== null) return { input: win, cwd: this.cwdOr(cwd) }
138
+ return { input: joinUnc(this.distroFor(cwd), path), cwd: this.cwdOr(cwd) }
139
+ }
140
+ if (windowsToMntPath(path) !== null) {
141
+ // Windows drive paths open directly; the Linux world reaches them via /mnt.
142
+ return { input: path, cwd: this.cwdOr(cwd) }
143
+ }
144
+ // Relative: resolve against the caller cwd (or the configured base).
145
+ const base = this.uncCwd(cwd)
146
+ return { input: path, cwd: base }
147
+ }
148
+
149
+ /** A base for absolute inputs (unused by resolution, but the parent needs one). */
150
+ private cwdOr(cwd?: string): string {
151
+ return cwd ?? this.executionCwd.getStore() ?? this.config.cwd ?? process.cwd()
152
+ }
153
+
154
+ private uncCwd(cwd?: string): string {
155
+ const base = cwd ?? this.executionCwd.getStore() ?? this.config.cwd
156
+ if (base === undefined || base === '') {
157
+ throw new FsError('wsl-fs: no cwd and no configured base for relative resolution', 'FS_IO_ERROR')
158
+ }
159
+ const unc = parseWslUnc(base)
160
+ if (unc !== null) return joinUnc(unc.distro, unc.linuxPath)
161
+ if (isAbsoluteLinuxPath(base)) return joinUnc(this.distroFor(base), base)
162
+ if (windowsToMntPath(base) !== null) return base
163
+ throw new FsError(`wsl-fs: cwd "${base}" is not in the WSL execution world`, 'FS_IO_ERROR')
164
+ }
165
+
166
+ /**
167
+ * Resolve the distribution an absolute Linux path opens inside. The chain:
168
+ * the caller cwd when it is a WSL UNC path, then the current tool
169
+ * execution's session cwd, then the configured `distro`,
170
+ * then the host's default distribution from the Lxss registry. The registry
171
+ * fallback is reserved for calls that genuinely have no session.
172
+ */
173
+ private distroFor(cwd?: string): string {
174
+ const fromCwd = parseWslUnc(cwd ?? '')
175
+ if (fromCwd !== null) return fromCwd.distro
176
+ const fromExecution = parseWslUnc(this.executionCwd.getStore() ?? '')
177
+ if (fromExecution !== null) return fromExecution.distro
178
+ const distro = this.distro
179
+ if (distro !== undefined && distro !== '') return distro
180
+ const fallback = defaultDistroSync()
181
+ if (fallback !== undefined) return fallback
182
+ throw new FsError('wsl-fs: Linux path carries no distribution and none is configured', 'FS_IO_ERROR')
183
+ }
184
+
185
+ /** The Linux display path for a resolved Windows-side path. */
186
+ private linuxDisplay(raw: string): string {
187
+ const unc = parseWslUnc(raw)
188
+ if (unc !== null) return unc.linuxPath
189
+ const mnt = windowsToMntPath(raw)
190
+ if (mnt !== null) return mnt
191
+ throw new FsError(`wsl-fs: resolved path "${raw}" is outside the WSL execution world`, 'FS_IO_ERROR')
192
+ }
193
+
194
+ override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
195
+ if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
196
+ const { input, cwd } = this.translate(path, opts?.cwd)
197
+ const local = await super.resolve(input, {
198
+ cwd,
199
+ ...opts?.signal !== undefined ? { signal: opts.signal } : {},
200
+ })
201
+ return { targetKey: local.targetKey, displayPath: this.linuxDisplay(String(local.displayPath)) }
202
+ }
203
+
204
+ override processPath(target: FsTarget): string {
205
+ const key = String(target.targetKey)
206
+ const unc = parseWslUnc(key)
207
+ if (unc !== null) return unc.linuxPath
208
+ const mnt = windowsToMntPath(key)
209
+ if (mnt !== null) return mnt
210
+ throw new FsError(`wsl-fs: target "${target.displayPath}" is outside the WSL execution world`, 'FS_IO_ERROR')
211
+ }
212
+
213
+ override fileUrl(target: FsTarget): string {
214
+ const linux = this.processPath(target)
215
+ const encoded = linux.split('/').map(encodeURIComponent).join('/')
216
+ return `file://${encoded}`
217
+ }
218
+
219
+ override contains(parent: FsTarget, child: FsTarget): boolean {
220
+ const parentWorld = this.worldPath(parent)
221
+ const childWorld = this.worldPath(child)
222
+ if (parentWorld.distro !== childWorld.distro) return false
223
+ const parentPath = parentWorld.linuxPath
224
+ const childPath = childWorld.linuxPath
225
+ if (childPath === parentPath) return true
226
+ return parentPath === '/' ? true : childPath.startsWith(`${parentPath}/`)
227
+ }
228
+
229
+ /** One target's (distro, linuxPath) pair for containment; `undefined` distro = Windows world. */
230
+ private worldPath(target: FsTarget): { distro: string | undefined; linuxPath: string } {
231
+ const key = String(target.targetKey)
232
+ const unc = parseWslUnc(key)
233
+ if (unc !== null) return { distro: unc.distro, linuxPath: unc.linuxPath }
234
+ const mnt = windowsToMntPath(key)
235
+ if (mnt !== null) return { distro: undefined, linuxPath: mnt }
236
+ throw new FsError(`wsl-fs: target "${target.displayPath}" is outside the WSL execution world`, 'FS_IO_ERROR')
237
+ }
238
+
239
+ override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
240
+ if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
241
+ if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
242
+ const { input, cwd } = this.translate(path, opts?.cwd)
243
+ return super.lstat(input, { cwd }, signal)
244
+ }
245
+ }
246
+
247
+ export default WslFileSystem