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
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
|
+
}
|
package/lib/wsl/paths.js
ADDED
|
@@ -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
|
+
}
|