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/lib/wsl/fs.js ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * `ctx.fs` provider for a WSL distribution.
3
+ *
4
+ * File I/O runs on the Windows side against the distribution's 9P share
5
+ * (`\\wsl.localhost\<distro>\…`) because that share needs no helper installed
6
+ * inside the distribution and keeps the harness's own read-before-edit,
7
+ * version-guard and atomic-write semantics. The provider's job is to make that
8
+ * share present one path dialect: everything the model and a WSL subprocess
9
+ * see is a Linux path, while the opaque target key stays a host path.
10
+ *
11
+ * The share does not implement three primitives `LocalFileSystem` relies on, so
12
+ * the constructor replaces them:
13
+ * - hard links fail with `ENOTSUP`, which breaks create-if-absent publication;
14
+ * - `ReplaceFileW` and `SetFileSecurityW` are local-volume Win32 APIs with no
15
+ * meaning on a network share.
16
+ * @module dsh-wsl-desktop/wsl/fs
17
+ */
18
+
19
+ import { copyFile, link, rename } from 'node:fs/promises'
20
+ import { constants as fsConstants } from 'node:fs'
21
+ import { posix, win32 } from 'node:path'
22
+ import { pathToFileURL } from 'node:url'
23
+ import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
24
+ import { FsError } from '@deepseek-ai/dsh-fs'
25
+ import z from '@deepseek-ai/schemastery'
26
+ import { isUnderHost, writableHostRootsFor } from './fence.js'
27
+ import { isWindowsPathShaped, joinWslUnc, mntToWindowsPath, parseWslUnc, windowsToMntPath } from './paths.js'
28
+
29
+ /** Share-level errors that mean "this primitive is unavailable", not "the operation failed". */
30
+ const PRIMITIVE_UNAVAILABLE = new Set(['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EINVAL'])
31
+
32
+ /** Default diff-basis ceiling, mirroring the local backend. */
33
+ const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024
34
+
35
+ /** Plugin config: the local backend's knobs plus the distribution choice. */
36
+ export const Config = z.object({
37
+ cwd: z.string().default(process.cwd()),
38
+ diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES),
39
+ /** Distribution used when a path does not already name one. */
40
+ distro: z.string(),
41
+ })
42
+
43
+ /**
44
+ * Resolve a caller-supplied path to a host path on the 9P share.
45
+ * @param {string} value - the path as the caller spelled it.
46
+ * @param {string | undefined} cwd - the caller's working directory.
47
+ * @param {string | undefined} fallbackDistro - distribution for a Linux path with no UNC context.
48
+ * @returns {string} an absolute Windows path the local backend can open.
49
+ * @throws Error when the path belongs to neither world.
50
+ */
51
+ export function toHostPath(value, cwd, fallbackDistro) {
52
+ if (typeof value !== 'string' || value.length === 0) throw new Error('fs-wsl: 路径不能为空')
53
+ const unc = parseWslUnc(value)
54
+ if (unc !== null) return joinWslUnc(unc.distro, posix.normalize(unc.linuxPath))
55
+ if (value.startsWith('/')) {
56
+ const drive = mntToWindowsPath(value)
57
+ if (drive !== null) return drive
58
+ const owner = parseWslUnc(cwd ?? '')
59
+ const distro = owner?.distro ?? fallbackDistro
60
+ if (distro === undefined) {
61
+ throw new Error(`fs-wsl: 无法确定 "${value}" 所属的发行版,请配置 distro`)
62
+ }
63
+ return joinWslUnc(distro, posix.normalize(value))
64
+ }
65
+ if (isWindowsPathShaped(value)) return value
66
+ // A relative path only has meaning inside the world its cwd names.
67
+ const owner = parseWslUnc(cwd ?? '')
68
+ if (owner !== null) return joinWslUnc(owner.distro, posix.join(owner.linuxPath, value))
69
+ if ((cwd ?? '').startsWith('/')) {
70
+ if (fallbackDistro === undefined) {
71
+ throw new Error(`fs-wsl: 无法确定相对路径 "${value}" 所属的发行版,请配置 distro`)
72
+ }
73
+ return joinWslUnc(fallbackDistro, posix.join(cwd, value))
74
+ }
75
+ if (isWindowsPathShaped(cwd ?? '')) return win32.join(cwd, value)
76
+ throw new Error(`fs-wsl: 既没有工作目录也没有发行版可以解析 "${value}"`)
77
+ }
78
+
79
+ /**
80
+ * Project a host path back into the Linux dialect the execution world uses.
81
+ * @param {string} value - a host path on the share or a drive path.
82
+ * @returns {string} the Linux spelling when one exists, else the input.
83
+ */
84
+ export function toLinuxPath(value) {
85
+ const unc = parseWslUnc(value)
86
+ if (unc !== null) return posix.normalize(unc.linuxPath)
87
+ const drive = windowsToMntPath(value)
88
+ if (drive !== null) return posix.normalize(drive)
89
+ return value
90
+ }
91
+
92
+ /**
93
+ * The WSL filesystem backend.
94
+ *
95
+ * It extends `LocalFileSystem` — so the atomic-write and read-match-write
96
+ * mechanics, the read-before-edit guard and the version basis are the local
97
+ * implementation's verbatim, plus this backend's replacements for the three
98
+ * primitives the 9P share does not implement — and adds the fence the tool
99
+ * layer reads, mirroring `@deepseek-ai/dsh-fs-sandbox` without inheriting it.
100
+ * (Inheriting it would wrap this class in a second backend whose row then
101
+ * depends on two realms' worth of services for no additional isolation: the
102
+ * fence is a policy check in trusted code, and it lives here.)
103
+ *
104
+ * The fence exists because `LocalFileSystem` never overrides
105
+ * `FileSystem.sandboxMode`: a backend that advertises nothing makes the tool
106
+ * layer's `FsSandboxController` resolve no policy for any call
107
+ * (`tool-fs/src/sandbox.ts:43-50`), so the `write` and `edit` tools ran
108
+ * unfenced and could write anywhere the share reaches, including `/mnt/c`.
109
+ *
110
+ * The rule mirrors the shipped backend: the per-call policy is what carries the
111
+ * workspace root (`resolvePolicy` always stamps the calling session's cwd), and
112
+ * containment is checked on the freshly canonical target so a concurrently
113
+ * swapped symlink ancestor cannot move the write outside it. The comparison
114
+ * itself lives in `./fence.js`, in the host namespace — a targetKey's UNC
115
+ * prefix carries the distribution — and is verified offline by
116
+ * `scripts/verify-fs-fence.mjs`.
117
+ */
118
+ export class WslFileSystem extends LocalFileSystem {
119
+ /** The realm inherits the host's policy service; the fence reads it per call. */
120
+ static inject = ['sandboxPolicy']
121
+
122
+ /** Declared so the fs row's `distro` pin survives schema validation. */
123
+ static Config = Config
124
+
125
+ /** The deployment default mode, captured for the sandboxMode capability fact. */
126
+ defaultMode
127
+
128
+ /**
129
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the preset realm context.
130
+ * @param {object} config - resolved plugin config.
131
+ */
132
+ constructor(ctx, config) {
133
+ super(ctx, config)
134
+ this.defaultMode = ctx.sandboxPolicy.defaultMode
135
+ this.internals = {
136
+ ...this.internals,
137
+ // 9P has no hard links; an exclusive copy keeps the same no-replace contract.
138
+ linkFile: async (existingPath, newPath) => {
139
+ try {
140
+ await link(existingPath, newPath)
141
+ } catch (error) {
142
+ if (!PRIMITIVE_UNAVAILABLE.has(error?.code)) throw error
143
+ await copyFile(existingPath, newPath, fsConstants.COPYFILE_EXCL)
144
+ }
145
+ },
146
+ // ReplaceFileW cannot address a share; a rename replaces in one step.
147
+ replaceFile: async (replaced, replacement) => {
148
+ await rename(replacement, replaced)
149
+ },
150
+ // The share exposes no settable DACL, and staging is already private.
151
+ copyFileDacl: async () => {},
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The deployment default mode — the capability fact the tool layer reads to
157
+ * decide that this backend confines and to advertise escalation. Declaring
158
+ * it is what makes `tool-fs` resolve a per-call policy at all
159
+ * (`tool-fs/src/sandbox.ts:43-50`).
160
+ * @returns {string} the default sandbox mode.
161
+ */
162
+ get sandboxMode() {
163
+ return this.defaultMode
164
+ }
165
+
166
+ /**
167
+ * The roots a workspace-write mutation may land under. Delegates to the pure
168
+ * {@link writableHostRootsFor} with this backend's distribution.
169
+ * @param {{ mode?: string, workspaceRoot?: string }} policy - the per-call policy.
170
+ * @returns {string[]} canonical host paths; empty unless workspace-write.
171
+ */
172
+ writableHostRoots(policy) {
173
+ return writableHostRootsFor(policy, this.config.distro)
174
+ }
175
+
176
+ /**
177
+ * Enforce the per-call policy against `target` and return the EXACT target
178
+ * the mutation must use, so the checked identity is the mutated one (no
179
+ * check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
180
+ * re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
181
+ * reflecting a concurrently swapped symlink), requires containment under a
182
+ * writable root, and returns THAT fresh target; `danger-full-access` returns
183
+ * the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
184
+ * refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
185
+ * and the escalation hint. A call that carries no policy resolves the
186
+ * session-less default, which carries no workspace root and so fails closed
187
+ * outside the temp areas — exactly how the shipped backend behaves.
188
+ * @param {{ targetKey: string, displayPath: string }} target - the resolved target to check.
189
+ * @param {{ mode?: string, workspaceRoot?: string }} [sandboxPolicy] - the per-call mode and workspace root.
190
+ * @returns {Promise<{ targetKey: string, displayPath: string }>} the fresh target to mutate.
191
+ */
192
+ async checkedTarget(target, sandboxPolicy) {
193
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
194
+ const { mode } = policy
195
+ if (mode === 'danger-full-access') return target
196
+ if (mode === 'read-only') {
197
+ throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
198
+ }
199
+ // Re-canonicalize from the TARGET KEY (the host spelling), not the
200
+ // display path: the display spelling is the Linux form and carries no
201
+ // distribution, so re-resolving it would pin the path to THIS backend's
202
+ // configured distro — silently redirecting a cross-distro UNC request to
203
+ // a same-spelled path elsewhere. The target key keeps the distribution
204
+ // in the string, so the checked identity is exactly the requested one.
205
+ const fresh = await this.resolve(target.targetKey)
206
+ for (const root of this.writableHostRoots(policy)) {
207
+ if (await isUnderHost(fresh.targetKey, root)) return fresh
208
+ }
209
+ throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
210
+ }
211
+
212
+ /**
213
+ * Fence the write by the per-call policy, then delegate to the inherited
214
+ * atomic write with the checked target.
215
+ * @param {{ targetKey: string, displayPath: string }} target - the resolved target to write.
216
+ * @param {string} content - the full new file content.
217
+ * @param {object} [expected] - the write intent guarding the write; omit for unconditional.
218
+ * @param {AbortSignal} [signal] - aborts before atomic publication takes effect.
219
+ * @param {{ mode?: string, workspaceRoot?: string }} [sandboxPolicy] - the per-call policy.
220
+ * @returns {Promise<object>} the write outcome from the inherited backend.
221
+ */
222
+ async writeText(target, content, expected, signal, sandboxPolicy) {
223
+ return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal)
224
+ }
225
+
226
+ /**
227
+ * Fence the edit by the per-call policy, then delegate to the inherited
228
+ * atomic edit with the checked target.
229
+ * @param {{ targetKey: string, displayPath: string }} target - the resolved target to edit.
230
+ * @param {object} edit - the literal search/replace request.
231
+ * @param {{ version: object }} [expected] - the version guard; omit for an unconditional edit.
232
+ * @param {AbortSignal} [signal] - aborts before atomic publication takes effect.
233
+ * @param {{ mode?: string, workspaceRoot?: string }} [sandboxPolicy] - the per-call policy.
234
+ * @returns {Promise<object>} the edit outcome from the inherited backend.
235
+ */
236
+ async editText(target, edit, expected, signal, sandboxPolicy) {
237
+ return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal)
238
+ }
239
+
240
+ /**
241
+ * Resolve a path to a target whose display spelling is a Linux path.
242
+ * @param {string} path - caller-supplied path.
243
+ * @param {{ cwd?: string, signal?: AbortSignal }} [opts] - cwd override and cancellation.
244
+ * @returns {Promise<{ targetKey: string, displayPath: string }>} the resolved target.
245
+ */
246
+ async resolve(path, opts) {
247
+ const host = toHostPath(path, opts?.cwd ?? this.config.cwd, this.config.distro)
248
+ const target = await super.resolve(host, opts?.signal ? { signal: opts.signal } : undefined)
249
+ return { targetKey: target.targetKey, displayPath: toLinuxPath(host) }
250
+ }
251
+
252
+ /**
253
+ * Inspect a path without following its final symlink.
254
+ * @param {string} path - caller-supplied path.
255
+ * @param {{ cwd?: string }} [opts] - cwd override.
256
+ * @param {AbortSignal} [signal] - cancellation.
257
+ * @returns {Promise<object | undefined>} path metadata.
258
+ */
259
+ async lstat(path, opts, signal) {
260
+ const host = toHostPath(path, opts?.cwd ?? this.config.cwd, this.config.distro)
261
+ return super.lstat(host, undefined, signal)
262
+ }
263
+
264
+ /**
265
+ * Return the Linux path a subprocess in this execution world can open.
266
+ * @param {{ targetKey: string }} target - a resolved target.
267
+ * @returns {string} the Linux path.
268
+ */
269
+ processPath(target) {
270
+ return toLinuxPath(String(target.targetKey))
271
+ }
272
+
273
+ /**
274
+ * Map a harness-host path into this execution world.
275
+ * @param {string} hostPath - absolute host path.
276
+ * @returns {string | undefined} the Linux path, or undefined when the file is not shared.
277
+ */
278
+ processPathFromHostPath(hostPath) {
279
+ if (typeof hostPath !== 'string' || hostPath.length === 0) return undefined
280
+ const unc = parseWslUnc(hostPath)
281
+ if (unc !== null) return posix.normalize(unc.linuxPath)
282
+ const drive = windowsToMntPath(hostPath)
283
+ return drive === null ? undefined : posix.normalize(drive)
284
+ }
285
+
286
+ /**
287
+ * Return the canonical file URI in the execution world.
288
+ *
289
+ * Built from the Linux path by hand: `pathToFileURL` applies the *host*
290
+ * platform's rules, so on Windows a Linux absolute path would gain a drive
291
+ * letter and produce `file:///C:/tmp/x` for `/tmp/x`.
292
+ * @param {{ targetKey: string }} target - a resolved target.
293
+ * @returns {string} the `file:` URI.
294
+ */
295
+ fileUrl(target) {
296
+ const path = this.processPath(target)
297
+ if (!path.startsWith('/')) return pathToFileURL(path).href
298
+ return `file://${path.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`
299
+ }
300
+
301
+ /**
302
+ * Test containment in the execution world's namespace.
303
+ *
304
+ * Distro-aware: `processPath` strips the `\\wsl.localhost\<distro>` prefix,
305
+ * so Linux spellings alone are ambiguous across distributions — when both
306
+ * target keys are WSL UNCs they must name the SAME distribution before the
307
+ * relative-path result is accepted (defense-in-depth; verified hardening
308
+ * from audit run-2, the only lower-trust consumer is absent from shipped
309
+ * compositions).
310
+ * @param {{ targetKey: string }} parent - canonical directory target.
311
+ * @param {{ targetKey: string }} child - candidate target.
312
+ * @returns {boolean} true when child is parent or below it.
313
+ */
314
+ contains(parent, child) {
315
+ const parentDistro = parseWslUnc(parent.targetKey)?.distro
316
+ const childDistro = parseWslUnc(child.targetKey)?.distro
317
+ if (parentDistro !== undefined && childDistro !== undefined
318
+ && parentDistro.toLowerCase() !== childDistro.toLowerCase()) return false
319
+ const relative = posix.relative(this.processPath(parent), this.processPath(child))
320
+ return relative === '' || (relative !== '..' && !relative.startsWith('../'))
321
+ }
322
+ }
323
+
324
+ export default WslFileSystem
@@ -0,0 +1,38 @@
1
+ /**
2
+ * References the host-side plugin captures for the realm-side providers.
3
+ *
4
+ * A preset realm that isolates `subprocess` cannot reach the host's provider
5
+ * through `ctx.subprocess` — that resolves to the realm's own provider. The
6
+ * host row runs in the root composition, so it captures the root provider here
7
+ * and the realm rows read it back. Both modules resolve to the same absolute
8
+ * URL, so they share one instance.
9
+ *
10
+ * The captured provider is the primitive that starts a *Windows* process, which
11
+ * is what every WSL provider ultimately needs: `wsl.exe` is an ordinary Windows
12
+ * executable, and starting it through the local provider keeps managed-range
13
+ * termination, output spill and disposal with its owner.
14
+ * @module dsh-wsl-desktop/wsl/host-refs
15
+ */
16
+
17
+ /** The root `ctx.subprocess` provider, once the host row has captured it. */
18
+ let localSubprocess
19
+
20
+ /**
21
+ * Record the host's subprocess provider.
22
+ * @param {object} provider - the root `ctx.subprocess` service.
23
+ */
24
+ export function setLocalSubprocess(provider) {
25
+ localSubprocess = provider
26
+ }
27
+
28
+ /**
29
+ * Read the host's subprocess provider.
30
+ * @returns {object} the root `ctx.subprocess` service.
31
+ * @throws Error when the host row has not captured it yet.
32
+ */
33
+ export function requireLocalSubprocess() {
34
+ if (localSubprocess === undefined) {
35
+ throw new Error('wsl-subprocess: 宿主 subprocess provider 尚未捕获')
36
+ }
37
+ return localSubprocess
38
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Pure path translation between the Windows host and a WSL distribution.
3
+ *
4
+ * Nothing here touches the filesystem: a workspace is identified by its UNC
5
+ * spelling (`\\wsl.localhost\<distro>\<linux>`) because that is the only form
6
+ * the Windows-side harness accepts as an absolute workspace path, while the
7
+ * distribution itself needs the Linux spelling. Every crossing converts
8
+ * explicitly instead of guessing from a separator.
9
+ * @module dsh-wsl-desktop/wsl/paths
10
+ */
11
+
12
+ /** UNC hosts Windows uses for the WSL 9P share. */
13
+ const UNC_HOSTS = new Set(['wsl.localhost', 'wsl$'])
14
+
15
+ /** A distribution name that is safe to concatenate into a UNC path. */
16
+ export const DISTRO_NAME = /^[A-Za-z0-9._-]+$/
17
+
18
+ /** A Linux user name that is safe to pass as a single wsl.exe -u option value. */
19
+ export const LINUX_USER = /^[A-Za-z0-9._][A-Za-z0-9._-]*\$?$/
20
+
21
+ /**
22
+ * Split a WSL UNC path into its distribution and Linux path.
23
+ * @param {unknown} raw - candidate path.
24
+ * @returns {{ distro: string, linuxPath: string } | null} the target, or null when the path is not a WSL UNC path.
25
+ */
26
+ export function parseWslUnc(raw) {
27
+ if (typeof raw !== 'string' || raw.length === 0) return null
28
+ const slashed = raw.replace(/\\/g, '/')
29
+ if (!slashed.startsWith('//')) return null
30
+ const segments = slashed.split('/').filter((segment) => segment !== '')
31
+ const host = (segments[0] ?? '').toLowerCase()
32
+ if (!UNC_HOSTS.has(host)) return null
33
+ const distro = segments[1] ?? ''
34
+ if (distro === '') return null
35
+ const rest = segments.slice(2).join('/')
36
+ return { distro, linuxPath: rest === '' ? '/' : `/${rest}` }
37
+ }
38
+
39
+ /**
40
+ * Build the UNC spelling of a path inside a distribution.
41
+ * @param {string} distro - distribution name.
42
+ * @param {string} linuxPath - absolute Linux path.
43
+ * @returns {string} the `\\wsl.localhost\<distro>\<linux>` spelling.
44
+ * @throws Error when the distribution name could escape the share root.
45
+ */
46
+ export function joinWslUnc(distro, linuxPath) {
47
+ if (!DISTRO_NAME.test(distro)) {
48
+ throw new Error(`wsl: distribution name ${JSON.stringify(distro)} is not usable in a UNC path`)
49
+ }
50
+ const posix = linuxPath.startsWith('/') ? linuxPath : `/${linuxPath}`
51
+ const suffix = posix === '/' ? '' : posix.replace(/\//g, '\\')
52
+ return `\\\\wsl.localhost\\${distro}${suffix}`
53
+ }
54
+
55
+ /**
56
+ * Translate a Windows drive path into its WSL mount path.
57
+ * @param {string} path - absolute Windows path.
58
+ * @returns {string | null} `/mnt/<drive>/…`, or null when the path is not a drive path.
59
+ */
60
+ export function windowsToMntPath(path) {
61
+ const match = /^([A-Za-z]):[\\/](.*)$/.exec(path)
62
+ if (match === null) return null
63
+ const rest = (match[2] ?? '').replace(/\\/g, '/')
64
+ return `/mnt/${(match[1] ?? '').toLowerCase()}${rest === '' ? '' : `/${rest}`}`
65
+ }
66
+
67
+ /**
68
+ * Translate a WSL drive mount back into a Windows path.
69
+ * @param {string} linuxPath - `/mnt/<drive>/…`.
70
+ * @returns {string | null} the Windows path, or null when the mount is not a single drive letter.
71
+ */
72
+ export function mntToWindowsPath(linuxPath) {
73
+ const match = /^\/mnt\/([A-Za-z])(?:\/(.*))?$/.exec(linuxPath)
74
+ if (match === null) return null
75
+ const rest = (match[2] ?? '').replace(/\//g, '\\')
76
+ return `${(match[1] ?? '').toUpperCase()}:\\${rest}`
77
+ }
78
+
79
+ /**
80
+ * Whether a value is spelled like a Windows path.
81
+ * @param {string} value - candidate value, typically an environment value.
82
+ * @returns {boolean} true for a drive or UNC spelling.
83
+ */
84
+ export function isWindowsPathShaped(value) {
85
+ return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
86
+ }
87
+
88
+ /**
89
+ * Quote one value for a POSIX shell word.
90
+ * @param {string} value - the raw value.
91
+ * @returns {string} a single-quoted shell word.
92
+ */
93
+ export function shellQuote(value) {
94
+ return `'${value.replace(/'/g, `'\\''`)}'`
95
+ }