dsh-plugin-wsl-env 0.6.0 → 0.7.3
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/CHANGELOG.md +229 -0
- package/README.md +6 -6
- package/agent/wsl-agent.sh +57 -4
- package/cordis.patch.yml +48 -0
- package/docs/ARCHITECTURE.md +33 -20
- package/docs/CONFIGURATION.md +2 -2
- package/docs/LIMITATIONS.md +52 -54
- package/docs/PARITY.md +18 -4
- package/docs/README.zh.md +7 -7
- package/lib/agent-confined.js +3 -3
- package/lib/agent-protocol.js +6 -4
- package/lib/agent-shared.js +14 -1
- package/lib/agent.js +52 -5
- package/lib/file-reference-wsl.js +284 -0
- package/lib/fs-decisions.js +22 -25
- package/lib/fs-routing.js +176 -0
- package/lib/git-route.js +142 -0
- package/lib/index.js +77 -307
- package/lib/listing.js +47 -0
- package/lib/picker.js +121 -17
- package/lib/search-exec.js +114 -0
- package/lib/search-route.js +95 -0
- package/lib/subprocess.js +153 -16
- package/lib/terminal-activity.js +121 -0
- package/lib/workspace-files-route.js +205 -0
- package/lib/workspace-files-wsl.js +341 -0
- package/lib/wsl.js +0 -39
- package/package.json +36 -16
- package/lib/fs-publish.js +0 -42
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The distro-side traversal strategy for `@` completion — the plugin's
|
|
3
|
+
* ready answer to RFC addendum ① (docs/upstream/rfc-wsl-workspaces.md).
|
|
4
|
+
*
|
|
5
|
+
* ## The seam this consumes
|
|
6
|
+
*
|
|
7
|
+
* `@deepseek-ai/dsh-file-reference-local` funnels every workspace traversal
|
|
8
|
+
* through three module-internal functions: `readWorkspaceRoot` and
|
|
9
|
+
* `readDirectory` (both `node:fs/promises` readdir with `withFileTypes`) and
|
|
10
|
+
* `resolveDisplayDirectory` (a lexical resolve plus a per-segment `lstat`
|
|
11
|
+
* walk that refuses symlink and non-directory components). Everything else —
|
|
12
|
+
* the fuzzy ranking, the bounded index, the generation/invalidation dance,
|
|
13
|
+
* the excluded-directory list — is pure logic over the entries those three
|
|
14
|
+
* return. Addendum ① proposes exposing the three as an injectable strategy
|
|
15
|
+
* with the current implementations as defaults; this module is the consumer
|
|
16
|
+
* side, built and tested against that interface so the takeover lands the
|
|
17
|
+
* day the seam does.
|
|
18
|
+
*
|
|
19
|
+
* It is deliberately NOT wired into cordis.patch.yml yet: with the seam
|
|
20
|
+
* absent, reaching the funnels would mean subclassing past module-internal
|
|
21
|
+
* functions or re-implementing the ranking engine — the behavior-regression
|
|
22
|
+
* path the RFC rules out. The module ships, tested, until upstream speaks.
|
|
23
|
+
*
|
|
24
|
+
* ## Semantics, held faithful where the seam allows
|
|
25
|
+
*
|
|
26
|
+
* - Entries keep upstream's Dirent shape: `{name, isDirectory(), isFile()}`.
|
|
27
|
+
* The distro listing uses `find -printf '%y\t%f'` rather than `ls -1ALp`
|
|
28
|
+
* precisely for this: `%y` reports `l` for symlinks, which the parse skips
|
|
29
|
+
* the way a readdir Dirent is neither file nor directory. `ls -1ALp` would
|
|
30
|
+
* resolve links through and change which candidates exist.
|
|
31
|
+
* - `resolveDisplayDirectory` refuses a symlink or non-directory component
|
|
32
|
+
* with one agent exec that walks the (lexically normalized) segments —
|
|
33
|
+
* the same verdict upstream reaches with N sequential lstats, and unlike
|
|
34
|
+
* `realpath`, refusing is the point, not resolving.
|
|
35
|
+
* - A directory the traversal cannot read yields `[]` (upstream's readdir
|
|
36
|
+
* catch); an aborted caller propagates the abort instead.
|
|
37
|
+
* - The returned absolutes stay in the root's own coordinate system (UNC for
|
|
38
|
+
* a distro workspace), because upstream joins children with the host
|
|
39
|
+
* `node:path` — a POSIX path from this layer would be mangled by it.
|
|
40
|
+
*
|
|
41
|
+
* @module dsh-plugin-wsl/file-reference-wsl
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { isWslUnc, posixToUnc, uncToPosix } from "./paths.js";
|
|
45
|
+
|
|
46
|
+
/** Upstream's default cap on one workspace index; the per-directory listing
|
|
47
|
+
* rides the same bound, since one level can't usefully exceed the budget. */
|
|
48
|
+
const DEFAULT_MAX_ENTRIES = 50000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lexically normalize a `/`-separated display directory against its root.
|
|
52
|
+
* Upstream resolves `resolve(root, displayDirectory)` and refuses a result
|
|
53
|
+
* outside the root, so `a/../b` lands on `b` while `..` past the root is
|
|
54
|
+
* refused outright — the walk below never sees a `..` segment.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} displayDirectory - the query text before the fragment, possibly "".
|
|
57
|
+
* @returns {string[] | undefined} the normalized segments, or undefined when
|
|
58
|
+
* the display directory escapes the root.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeDisplaySegments(displayDirectory) {
|
|
61
|
+
const segments = [];
|
|
62
|
+
for (const segment of String(displayDirectory).split("/")) {
|
|
63
|
+
if (segment === "" || segment === ".") continue;
|
|
64
|
+
if (segment === "..") {
|
|
65
|
+
if (segments.length === 0) return undefined;
|
|
66
|
+
segments.pop();
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
segments.push(segment);
|
|
70
|
+
}
|
|
71
|
+
return segments;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The distro argv for one directory listing: `find -printf '%y\t%f'` over the
|
|
76
|
+
* contained directory. The containment guard is the same shape the picker and
|
|
77
|
+
* workspace-files use — `realpath` the requested directory, refuse anything
|
|
78
|
+
* the root does not contain — and the `%y` type char is what keeps symlink
|
|
79
|
+
* semantics identical to a readdir Dirent (skipped, not followed).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} linuxRoot - the workspace root, POSIX spelling.
|
|
82
|
+
* @param {string} linuxDir - the directory to list, POSIX spelling.
|
|
83
|
+
* @param {number} maxEntries - the listing cap (head -n bound).
|
|
84
|
+
* @returns {string[]} the agent exec argv.
|
|
85
|
+
*/
|
|
86
|
+
export function traversalArgv(linuxRoot, linuxDir, maxEntries) {
|
|
87
|
+
const script = [
|
|
88
|
+
"root=$1 dir=$2 max=$3",
|
|
89
|
+
'real=$(realpath -L -- "$dir") || exit 2',
|
|
90
|
+
'case "$real" in "$root") ;; "$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac',
|
|
91
|
+
'[ -d "$real" ] || { echo "NOTDIR:$real" >&2; exit 4; }',
|
|
92
|
+
'find "$real" -mindepth 1 -maxdepth 1 -printf \'%y\\t%f\\n\' | head -n "$max"',
|
|
93
|
+
].join("\n");
|
|
94
|
+
return ["sh", "-c", script, "sh", linuxRoot, linuxDir, maxEntries];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse one `%y\t%f` listing into upstream's Dirent-like entries.
|
|
99
|
+
* `d` is a directory and `f` a file; every other type char — `l` for
|
|
100
|
+
* symlinks, devices, sockets — is skipped, matching a readdir Dirent.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} stdout - the raw listing.
|
|
103
|
+
* @returns {{name: string, isDirectory: () => boolean, isFile: () => boolean}[]}
|
|
104
|
+
*/
|
|
105
|
+
export function parseTraversalListing(stdout) {
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (const line of String(stdout).split("\n")) {
|
|
108
|
+
const tab = line.indexOf("\t");
|
|
109
|
+
if (tab <= 0) continue;
|
|
110
|
+
const kind = line.slice(0, tab);
|
|
111
|
+
const name = line.slice(tab + 1);
|
|
112
|
+
if (name.length === 0) continue;
|
|
113
|
+
if (kind !== "d" && kind !== "f") continue;
|
|
114
|
+
entries.push({
|
|
115
|
+
name,
|
|
116
|
+
isDirectory: () => kind === "d",
|
|
117
|
+
isFile: () => kind === "f",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The argv for the per-segment walk behind `resolveDisplayDirectory`: each
|
|
125
|
+
* segment must exist, be a directory, and not be a symlink — upstream's
|
|
126
|
+
* lstat verdicts, checked in one exec. Segments ride as argv elements, never
|
|
127
|
+
* interpolated into the script.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} linuxRoot - the workspace root, POSIX spelling.
|
|
130
|
+
* @param {string[]} segments - the normalized display segments below the root.
|
|
131
|
+
* @returns {string[]} the agent exec argv.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveDirectoryArgv(linuxRoot, segments) {
|
|
134
|
+
const script = [
|
|
135
|
+
"root=$1",
|
|
136
|
+
'cur="$root"',
|
|
137
|
+
'for seg in "${@:2}"; do',
|
|
138
|
+
' cur="$cur/$seg"',
|
|
139
|
+
' if [ -L "$cur" ]; then echo "SYMLINK:$cur" >&2; exit 5; fi',
|
|
140
|
+
' [ -d "$cur" ] || { echo "NOTDIR:$cur" >&2; exit 4; }',
|
|
141
|
+
"done",
|
|
142
|
+
'printf \'%s\\n\' "$cur"',
|
|
143
|
+
].join("\n");
|
|
144
|
+
return ["sh", "-c", script, "sh", linuxRoot, ...segments];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The host-default traversal — the node:fs implementations the seam keeps
|
|
149
|
+
* as upstream's defaults, replicated here so the prototype answers host
|
|
150
|
+
* workspaces without the seam and so tests can inject substitutes for it.
|
|
151
|
+
* (Upstream owns the real defaults; this copy exists only until the takeover.)
|
|
152
|
+
*
|
|
153
|
+
* @param {object} deps - the node:fs/promises functions, injectable for tests.
|
|
154
|
+
* @returns the strategy object with the upstream default semantics.
|
|
155
|
+
*/
|
|
156
|
+
export function hostFileReferenceTraversal(deps) {
|
|
157
|
+
const read = async (absolute, signal) => {
|
|
158
|
+
signal.throwIfAborted();
|
|
159
|
+
try {
|
|
160
|
+
const entries = await deps.readdir(absolute, { withFileTypes: true });
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
return entries;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
signal.throwIfAborted();
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
readWorkspaceRoot: read,
|
|
170
|
+
readDirectory: async (absolute, signal) => {
|
|
171
|
+
signal.throwIfAborted();
|
|
172
|
+
try {
|
|
173
|
+
const entries = await deps.readdir(absolute, { withFileTypes: true });
|
|
174
|
+
signal.throwIfAborted();
|
|
175
|
+
return entries;
|
|
176
|
+
} catch {
|
|
177
|
+
signal.throwIfAborted();
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
resolveDisplayDirectory: async (root, displayDirectory, signal) => {
|
|
182
|
+
const segments = normalizeDisplaySegments(displayDirectory);
|
|
183
|
+
if (segments === undefined) return undefined;
|
|
184
|
+
let current = root;
|
|
185
|
+
for (const segment of segments) {
|
|
186
|
+
signal.throwIfAborted();
|
|
187
|
+
current = `${current}${current.endsWith("/") ? "" : "/"}${segment}`;
|
|
188
|
+
try {
|
|
189
|
+
const status = await deps.lstat(current);
|
|
190
|
+
signal.throwIfAborted();
|
|
191
|
+
if (status.isSymbolicLink() || !status.isDirectory()) return undefined;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
signal.throwIfAborted();
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return current;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The distro-routed traversal strategy. Each method routes by coordinate:
|
|
204
|
+
* a `\\wsl.localhost\<distro>` UNC rides the resident agent (the search
|
|
205
|
+
* root and every absolute upstream passes stay in the UNC form it joined
|
|
206
|
+
* them with); anything else defers to the default traversal. The search
|
|
207
|
+
* root doubles as the containment root for the distro listings' guard, so
|
|
208
|
+
* the seam wires one strategy per `WorkspaceFileSearch` — exactly where
|
|
209
|
+
* `WorkspaceFileSearch(root, config, traversal)` would take it.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} options
|
|
212
|
+
* @param {string} options.root - the search root, the UNC form upstream passes.
|
|
213
|
+
* @param {(distro: string) => object} options.runnerFor - the agent runner
|
|
214
|
+
* for one distro; tests substitute a fake (a real wiring passes sharedAgent).
|
|
215
|
+
* @param {object} options.defaultTraversal - the host-default strategy.
|
|
216
|
+
* @param {number} [options.maxEntries] - the listing cap, upstream's index budget.
|
|
217
|
+
* @returns the strategy object the seam's `WorkspaceFileSearch` would take.
|
|
218
|
+
*/
|
|
219
|
+
export function wslFileReferenceTraversal({ root, runnerFor, defaultTraversal, maxEntries = DEFAULT_MAX_ENTRIES }) {
|
|
220
|
+
if (!isWslUnc(root)) return defaultTraversal;
|
|
221
|
+
const rootCoords = uncToPosix(root);
|
|
222
|
+
const rootLinux = rootCoords.linuxPath;
|
|
223
|
+
|
|
224
|
+
const listDistro = async (absolute, signal, onRefusal) => {
|
|
225
|
+
signal.throwIfAborted();
|
|
226
|
+
const { linuxPath } = uncToPosix(absolute);
|
|
227
|
+
try {
|
|
228
|
+
const result = await runnerFor(rootCoords.distro).exec({
|
|
229
|
+
cwd: "/",
|
|
230
|
+
argv: traversalArgv(rootLinux, linuxPath, maxEntries),
|
|
231
|
+
maxOutputBytes: 4_000_000,
|
|
232
|
+
timeoutMs: 30_000,
|
|
233
|
+
signal,
|
|
234
|
+
});
|
|
235
|
+
signal.throwIfAborted();
|
|
236
|
+
if (result.exitCode !== 0) {
|
|
237
|
+
return onRefusal(new Error(`listing "${linuxPath}" failed: ${String(result.stderr).trim() || `exit ${result.exitCode}`}`));
|
|
238
|
+
}
|
|
239
|
+
return parseTraversalListing(result.stdout);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
signal.throwIfAborted();
|
|
242
|
+
return onRefusal(error);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
readWorkspaceRoot(absolute, signal) {
|
|
248
|
+
if (!isWslUnc(absolute)) return defaultTraversal.readWorkspaceRoot(absolute, signal);
|
|
249
|
+
return listDistro(absolute, signal, (error) => {
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
readDirectory(absolute, signal) {
|
|
255
|
+
if (!isWslUnc(absolute)) return defaultTraversal.readDirectory(absolute, signal);
|
|
256
|
+
return listDistro(absolute, signal, () => []);
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async resolveDisplayDirectory(rootOf, displayDirectory, signal) {
|
|
260
|
+
signal.throwIfAborted();
|
|
261
|
+
if (!isWslUnc(rootOf)) return defaultTraversal.resolveDisplayDirectory(rootOf, displayDirectory, signal);
|
|
262
|
+
const segments = normalizeDisplaySegments(displayDirectory);
|
|
263
|
+
if (segments === undefined) return undefined;
|
|
264
|
+
if (segments.length === 0) return root;
|
|
265
|
+
try {
|
|
266
|
+
const result = await runnerFor(rootCoords.distro).exec({
|
|
267
|
+
cwd: "/",
|
|
268
|
+
argv: resolveDirectoryArgv(rootLinux, segments),
|
|
269
|
+
maxOutputBytes: 4_000_000,
|
|
270
|
+
timeoutMs: 30_000,
|
|
271
|
+
signal,
|
|
272
|
+
});
|
|
273
|
+
signal.throwIfAborted();
|
|
274
|
+
if (result.exitCode !== 0) return undefined;
|
|
275
|
+
return posixToUnc(rootCoords.distro, result.stdout.trim());
|
|
276
|
+
} catch {
|
|
277
|
+
signal.throwIfAborted();
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default wslFileReferenceTraversal;
|
package/lib/fs-decisions.js
CHANGED
|
@@ -79,37 +79,33 @@ export function workspaceWriteDenial(displayPath) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* The
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* @
|
|
82
|
+
* The substrate configuration decision: the Windows-side share no longer
|
|
83
|
+
* serves the file tools. Every distro read and write runs on the resident
|
|
84
|
+
* agent, so no model-facing I/O crosses the 9p share at all — the share
|
|
85
|
+
* backend it replaces was the one path that still could. The provider throws
|
|
86
|
+
* this at construction time, before any session forms, so a profile carrying
|
|
87
|
+
* the old value fails loudly with the migration instead of silently serving
|
|
88
|
+
* I/O the configuration did not mean to allow.
|
|
89
|
+
* @param {string} substrate - the configured substrate value.
|
|
90
|
+
* @returns {{message: string}|null} the refusal, or null to proceed.
|
|
90
91
|
*/
|
|
91
|
-
export function
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
if (expected.kind === "createIfAbsent" && existing !== undefined) {
|
|
101
|
-
return refusal(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
102
|
-
}
|
|
103
|
-
return null;
|
|
92
|
+
export function shareSubstrateRefusal(substrate) {
|
|
93
|
+
if (substrate !== "share") return null;
|
|
94
|
+
return {
|
|
95
|
+
message:
|
|
96
|
+
'wsl-fs: substrate "share" is no longer available — the resident agent serves every ' +
|
|
97
|
+
"distro read and write, and no file tool crosses the 9p share. Delete the substrate " +
|
|
98
|
+
'line from the provider config, or set it to "agent".',
|
|
99
|
+
};
|
|
104
100
|
}
|
|
105
101
|
|
|
106
102
|
/**
|
|
107
103
|
* The substrate failure mapping: what the tool layer should surface for an
|
|
108
104
|
* error that crossed the `ctx.fs` seam. A coded refusal keeps its code and
|
|
109
105
|
* message byte-for-byte; anything else is the substrate being UNAVAILABLE —
|
|
110
|
-
* the
|
|
111
|
-
*
|
|
112
|
-
*
|
|
106
|
+
* the resident agent that serves every distro read and write is out — and the
|
|
107
|
+
* message names the recovery path, because there is no fallback I/O for it to
|
|
108
|
+
* point at.
|
|
113
109
|
* @param {unknown} error - what the substrate raised.
|
|
114
110
|
* @returns {{message: string, code: string, cause: unknown}} the refusal the
|
|
115
111
|
* provider wraps in `FsError`.
|
|
@@ -120,7 +116,8 @@ export function substrateFailure(error) {
|
|
|
120
116
|
return {
|
|
121
117
|
message:
|
|
122
118
|
`the distro file substrate is unavailable: ${message}. ` +
|
|
123
|
-
`
|
|
119
|
+
`The resident agent serves every distro read and write; check the distro is running ` +
|
|
120
|
+
`("wsl.exe -l -v") and restart the session if it stays out.`,
|
|
124
121
|
code: "FS_IO_ERROR",
|
|
125
122
|
cause: error instanceof Error ? error : undefined,
|
|
126
123
|
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root-plane filesystem with coordinate routing: targets under a
|
|
3
|
+
* `\\wsl.localhost\<distro>` identity are served by the distro substrate
|
|
4
|
+
* (the resident agent, on ext4); every other target — drive-letter paths,
|
|
5
|
+
* the deployment cwd — is served by the host `LocalFileSystem` this class
|
|
6
|
+
* extends, unchanged.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a router, and why here
|
|
9
|
+
*
|
|
10
|
+
* The root `ctx.fs` is session-less: it cannot be switched per session the
|
|
11
|
+
* way a preset's isolated `fs` can, so the two workspace kinds share one
|
|
12
|
+
* backend — and the stock backend serves the distro workspace across the 9p
|
|
13
|
+
* share, in its slow direction, with no usable change notifications. Routing
|
|
14
|
+
* by coordinate makes every root consumer (the workspace-files change feed
|
|
15
|
+
* among them) correct on both workspace kinds without per-consumer patches.
|
|
16
|
+
*
|
|
17
|
+
* The design note is `docs/root-fs-routing.md` (the operation routing table,
|
|
18
|
+
* the intended behavior differences, and the dormant-root-fence rationale).
|
|
19
|
+
*
|
|
20
|
+
* ## What is intentionally not here
|
|
21
|
+
*
|
|
22
|
+
* No write fence: the root plane has no write consumers (the model's writes
|
|
23
|
+
* ride the per-session preset filesystems, which enforce their own
|
|
24
|
+
* containment — the distro side kernel-enforced). If a root-plane write
|
|
25
|
+
* consumer appears, its fence rides with that consumer.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-plugin-wsl/fs-routing
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
31
|
+
import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
|
|
32
|
+
import { linuxJoin } from "./workspace-files-route.js";
|
|
33
|
+
import { WslFileSystem } from "./index.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a resolved target identity belongs to the distro.
|
|
37
|
+
*
|
|
38
|
+
* @param targetKey - the target's identity (a UNC for distro paths).
|
|
39
|
+
* @returns true when the target lives inside a WSL distro.
|
|
40
|
+
*/
|
|
41
|
+
export function targetIsDistro(targetKey) {
|
|
42
|
+
return isWslUnc(targetKey);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a raw path-or-cwd input names the distro (used by the operations
|
|
47
|
+
* that take paths before a target exists: resolve, lstat).
|
|
48
|
+
*
|
|
49
|
+
* @param value - a path in any coordinate system, or undefined.
|
|
50
|
+
* @returns true when the input names a distro UNC.
|
|
51
|
+
*/
|
|
52
|
+
export function inputIsDistro(value) {
|
|
53
|
+
return value !== undefined && isWslUnc(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The Linux working path for one request: UNC absolutes map onto their Linux
|
|
58
|
+
* paths, POSIX absolutes and relative paths join onto the root.
|
|
59
|
+
*
|
|
60
|
+
* @param linuxRoot - the workspace root in Linux form.
|
|
61
|
+
* @param path - the requested path, any coordinate system.
|
|
62
|
+
* @returns the absolute Linux path.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* The root-plane filesystem with coordinate routing.
|
|
66
|
+
*
|
|
67
|
+
* The distro backend is built lazily on the first routed target, sharing this
|
|
68
|
+
* context (the root `sandboxPolicy` service provides the deployment default
|
|
69
|
+
* mode the distro side reports through `sandboxMode`).
|
|
70
|
+
*/
|
|
71
|
+
export class WslRoutingFileSystem extends LocalFileSystem {
|
|
72
|
+
static Config = LocalFileSystem.Config;
|
|
73
|
+
|
|
74
|
+
/** The lazy distro backend, built on first routed use. */
|
|
75
|
+
#distro;
|
|
76
|
+
/** The distro knobs, verbatim from the row config. */
|
|
77
|
+
#distroConfig;
|
|
78
|
+
|
|
79
|
+
constructor(ctx, config) {
|
|
80
|
+
super(ctx, config);
|
|
81
|
+
this.#distroConfig = {
|
|
82
|
+
distro: config.distro ?? "",
|
|
83
|
+
wslPath: config.wslPath ?? "wsl.exe",
|
|
84
|
+
cwd: config.distroCwd ?? "",
|
|
85
|
+
diffBasisMaxBytes: config.diffBasisMaxBytes,
|
|
86
|
+
sandbox: config.sandbox ?? true,
|
|
87
|
+
maskWindowsDrive: config.maskWindowsDrive ?? false,
|
|
88
|
+
restrictToDistro: config.restrictToDistro ?? true,
|
|
89
|
+
watchMaxDepth: config.watchMaxDepth ?? 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The distro backend for routed targets, built on first use. */
|
|
94
|
+
distro() {
|
|
95
|
+
return (this.#distro ??= new WslFileSystem(this.ctx, this.#distroConfig));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async resolve(path, opts) {
|
|
99
|
+
if (inputIsDistro(path) || inputIsDistro(opts?.cwd)) return (await this.distro()).resolve(path, opts);
|
|
100
|
+
return super.resolve(path, opts);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async lstat(path, opts, signal) {
|
|
104
|
+
if (inputIsDistro(path) || inputIsDistro(opts?.cwd)) return (await this.distro()).lstat(path, opts, signal);
|
|
105
|
+
return super.lstat(path, opts, signal);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async stat(target, signal) {
|
|
109
|
+
if (targetIsDistro(target.targetKey)) return (await this.distro()).stat(target, signal);
|
|
110
|
+
return super.stat(target, signal);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async readText(target, signal) {
|
|
114
|
+
if (targetIsDistro(target.targetKey)) return (await this.distro()).readText(target, signal);
|
|
115
|
+
return super.readText(target, signal);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async streamText(target, signal) {
|
|
119
|
+
if (targetIsDistro(target.targetKey)) return (await this.distro()).streamText(target, signal);
|
|
120
|
+
return super.streamText(target, signal);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async readBytes(target, signal, maxBytes) {
|
|
124
|
+
if (targetIsDistro(target.targetKey)) return (await this.distro()).readBytes(target, signal, maxBytes);
|
|
125
|
+
return super.readBytes(target, signal, maxBytes);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async readByteRange(target, range, signal) {
|
|
129
|
+
if (targetIsDistro(target.targetKey)) return (await this.distro()).readByteRange(target, range, signal);
|
|
130
|
+
return super.readByteRange(target, range, signal);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async listDir(target, signal) {
|
|
134
|
+
if (targetIsDistro(target.targetKey)) return (await this.distro()).listDir(target, signal);
|
|
135
|
+
return super.listDir(target, signal);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
139
|
+
if (targetIsDistro(target.targetKey)) {
|
|
140
|
+
return (await this.distro()).writeText(target, content, expected, signal, sandboxPolicy);
|
|
141
|
+
}
|
|
142
|
+
return super.writeText(target, content, expected, signal, sandboxPolicy);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
146
|
+
if (targetIsDistro(target.targetKey)) {
|
|
147
|
+
return (await this.distro()).editText(target, edit, expected, signal, sandboxPolicy);
|
|
148
|
+
}
|
|
149
|
+
return super.editText(target, edit, expected, signal, sandboxPolicy);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async watch(target, changed, signal) {
|
|
153
|
+
if (targetIsDistro(target.targetKey)) return (await this.distro()).watch(target, changed, signal);
|
|
154
|
+
return super.watch(target, changed, signal);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
contains(parent, child) {
|
|
158
|
+
if (targetIsDistro(parent.targetKey)) {
|
|
159
|
+
// Both targets came from this router's distro resolve, so the plain
|
|
160
|
+
// POSIX containment over their process paths is the same check the
|
|
161
|
+
// distro substrate performs for its own guarded writes.
|
|
162
|
+
const base = this.processPath(parent);
|
|
163
|
+
const full = this.processPath(child);
|
|
164
|
+
return full === base || full.startsWith(base.endsWith("/") ? base : `${base}/`);
|
|
165
|
+
}
|
|
166
|
+
return super.contains(parent, child);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
processPath(target) {
|
|
170
|
+
const parsed = uncToPosix(target.targetKey);
|
|
171
|
+
if (parsed !== undefined) return parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
|
|
172
|
+
return super.processPath(target);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default WslRoutingFileSystem;
|
package/lib/git-route.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which git spawns belong inside the distro, and the `wsl.exe` argv they
|
|
3
|
+
* become there.
|
|
4
|
+
*
|
|
5
|
+
* `workspace-changes` snapshots a workspace's git state by spawning the host
|
|
6
|
+
* git through `ctx.subprocess` with the workspace as the working directory.
|
|
7
|
+
* On a distro workspace that spawn runs Windows git against the 9P share —
|
|
8
|
+
* the same slow direction as the search path, and the snapshot's
|
|
9
|
+
* `git add --all` is a full work-tree scan, the most metadata-heavy operation
|
|
10
|
+
* there is. The interception point is the same `spawn` override; the decision
|
|
11
|
+
* lives here, pure and unit-testable, mirroring `search-route.js`.
|
|
12
|
+
*
|
|
13
|
+
* ## What is routed, and what deliberately is not
|
|
14
|
+
*
|
|
15
|
+
* A spawn is routed only when the working directory names the distro (a
|
|
16
|
+
* `\\wsl.localhost\<distro>` UNC) and the argv carries none of the discovery
|
|
17
|
+
* flags (`--show-toplevel`, `--absolute-git-dir`, `--git-path`). Those three
|
|
18
|
+
* print **absolute paths**, which `workspace-changes` resolves against the
|
|
19
|
+
* UNC workspace (`git.ts` `resolve(cwd, line)`) — a distro git would answer
|
|
20
|
+
* in Linux paths and corrupt that resolution. Host git answers in UNC form,
|
|
21
|
+
* which is exactly what the caller wants, and the discovery call is one
|
|
22
|
+
* cheap command rather than a work-tree scan. Everything else — `add`,
|
|
23
|
+
* `write-tree`, `ls-tree`, `cat-file`, `diff-tree`, `ls-files`,
|
|
24
|
+
* `check-ignore` — is safe to route: snapshot calls run in an environment
|
|
25
|
+
* fully isolated from the user's git state (`GIT_INDEX_FILE` /
|
|
26
|
+
* `GIT_OBJECT_DIRECTORY` / `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the
|
|
27
|
+
* tool's private scratch), so the distro git mutates scratch, never the
|
|
28
|
+
* repository.
|
|
29
|
+
*
|
|
30
|
+
* ## Environment translation
|
|
31
|
+
*
|
|
32
|
+
* The routed spawn must forward the caller's `GIT_*` environment into the
|
|
33
|
+
* distro, and its values are paths in Windows form (`\\wsl.localhost\...`
|
|
34
|
+
* identities and drive-letter scratch dirs). `translateGitEnv` rewrites those
|
|
35
|
+
* two shapes into distro paths with the same pure translations the fs seam
|
|
36
|
+
* uses, forwards every other value verbatim (`GIT_CONFIG_COUNT=0` and
|
|
37
|
+
* friends), and returns the names that must ride `WSLENV`. One documented
|
|
38
|
+
* limit: a colon-separated multi-directory value (none of the current
|
|
39
|
+
* callers set one) passes through untranslated.
|
|
40
|
+
*
|
|
41
|
+
* @module dsh-plugin-wsl/git-route
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
45
|
+
|
|
46
|
+
/** The one program this rewrite passes through to the distro. */
|
|
47
|
+
const GIT_PROGRAM = "git";
|
|
48
|
+
|
|
49
|
+
/** Discovery flags print absolute paths — host git keeps that contract. */
|
|
50
|
+
const DISCOVERY_FLAGS = ["--show-toplevel", "--absolute-git-dir", "--git-path"];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a spawn's program is git.
|
|
54
|
+
* @param unknown - the spawn's `argv[0]`.
|
|
55
|
+
* @returns true when the spawn is a git launch.
|
|
56
|
+
*/
|
|
57
|
+
export function isGitProgram(program) {
|
|
58
|
+
if (typeof program !== "string" || program.length === 0) return false;
|
|
59
|
+
const base = program.slice(Math.max(program.lastIndexOf("/"), program.lastIndexOf("\\")) + 1);
|
|
60
|
+
return base.toLowerCase().replace(/\.exe$/u, "") === GIT_PROGRAM;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a git argv carries the discovery flags whose absolute output the
|
|
65
|
+
* caller resolves against the UNC world.
|
|
66
|
+
* @param {unknown[]} argv - the spawn's argv; `argv[0]` is the program.
|
|
67
|
+
* @returns true when the spawn must stay on host git.
|
|
68
|
+
*/
|
|
69
|
+
function namesDiscoveryPaths(argv) {
|
|
70
|
+
return argv.some((arg) => typeof arg === "string" && DISCOVERY_FLAGS.includes(arg));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Translate one `GIT_*` value into the distro's coordinate system.
|
|
75
|
+
*
|
|
76
|
+
* UNC identities (`\\wsl.localhost\<distro>\...`) map onto their Linux paths;
|
|
77
|
+
* drive-letter paths map onto their `/mnt/<drive>` mounts; anything else is
|
|
78
|
+
* not a path we can name and passes through verbatim.
|
|
79
|
+
*
|
|
80
|
+
* @param value - the env value.
|
|
81
|
+
* @returns the distro-side spelling of the value.
|
|
82
|
+
*/
|
|
83
|
+
export function translateGitEnvValue(value) {
|
|
84
|
+
if (typeof value !== "string") return value;
|
|
85
|
+
if (isWslUnc(value)) {
|
|
86
|
+
const parsed = uncToPosix(value);
|
|
87
|
+
return parsed === undefined ? value : parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
|
|
88
|
+
}
|
|
89
|
+
const mounted = windowsToLinuxMount(value);
|
|
90
|
+
return mounted === undefined ? value : mounted;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Translate a git spawn's environment for the distro and name the keys that
|
|
95
|
+
* must ride `WSLENV`.
|
|
96
|
+
*
|
|
97
|
+
* @param env - the spawn's environment, or undefined.
|
|
98
|
+
* @returns the environment to forward (every key, path-shaped values
|
|
99
|
+
* translated) and the `WSLENV` names.
|
|
100
|
+
*/
|
|
101
|
+
export function translateGitEnv(env) {
|
|
102
|
+
const translated = {};
|
|
103
|
+
const names = [];
|
|
104
|
+
for (const [name, value] of Object.entries(env ?? {})) {
|
|
105
|
+
translated[name] = name.startsWith("GIT_") ? translateGitEnvValue(value) : value;
|
|
106
|
+
names.push(name);
|
|
107
|
+
}
|
|
108
|
+
return { env: translated, wslenv: names };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The distro-side rewrite of one git spawn, when it belongs in the distro.
|
|
113
|
+
*
|
|
114
|
+
* `gitArgv` is the distro command itself; `wslArgv` is the same command
|
|
115
|
+
* wrapped for a one-shot `wsl.exe --exec` launch — git snapshot operations
|
|
116
|
+
* are not latency-sensitive, so the routing stays on the simple transport
|
|
117
|
+
* and does not need the resident agent. `wsl.exe` itself must start in a
|
|
118
|
+
* Windows directory, so the caller supplies its own host cwd.
|
|
119
|
+
*
|
|
120
|
+
* @param object - the spawn's inputs.
|
|
121
|
+
* @param {unknown[]} object.argv - the spawn's argv; `argv[0]` is the program.
|
|
122
|
+
* @param {unknown} object.cwd - the spawn's working directory, any coordinate system.
|
|
123
|
+
* @param {string} object.wslPath - path to `wsl.exe`.
|
|
124
|
+
* @returns {{distro: string, linuxCwd: string, gitArgv: string[], wslArgv: string[]}|undefined} the
|
|
125
|
+
* rewrite, or undefined when the spawn is not a routable distro git launch
|
|
126
|
+
* (non-git programs, host directories, and the absolute-output discovery
|
|
127
|
+
* flags all keep the shipped spawn).
|
|
128
|
+
*/
|
|
129
|
+
export function gitSpawnRewrite({ argv, cwd, wslPath }) {
|
|
130
|
+
if (!Array.isArray(argv) || !isGitProgram(argv[0])) return undefined;
|
|
131
|
+
if (namesDiscoveryPaths(argv)) return undefined;
|
|
132
|
+
const named = uncToPosix(cwd);
|
|
133
|
+
if (named === undefined) return undefined;
|
|
134
|
+
const linuxCwd = named.linuxPath.length > 0 ? named.linuxPath : "/";
|
|
135
|
+
const gitArgv = [GIT_PROGRAM, ...argv.slice(1)];
|
|
136
|
+
return {
|
|
137
|
+
distro: named.distro,
|
|
138
|
+
linuxCwd,
|
|
139
|
+
gitArgv,
|
|
140
|
+
wslArgv: [wslPath, "-d", named.distro, "--cd", linuxCwd, "--exec", ...gitArgv],
|
|
141
|
+
};
|
|
142
|
+
}
|