dsh-plugin-wsl-env 0.5.0 → 0.7.2

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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pure builders and parsers for the distro-routed `workspace-files` service —
3
+ * the GUI file tree and previews for a `\\wsl.localhost\<distro>` workspace,
4
+ * served from inside the distro instead of Windows-side 9P walks.
5
+ *
6
+ * Peer-free by construction (no DSH imports): the service subclass imports
7
+ * this alongside the shipped `dsh-api-workspace-files` base class, and the
8
+ * unit tests import it standalone.
9
+ *
10
+ * @module dsh-plugin-wsl/workspace-files-route
11
+ */
12
+
13
+ import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
14
+
15
+ /**
16
+ * Translate one path-shaped value into distro coordinates: UNC identities map
17
+ * onto their Linux paths, drive-letter paths onto their `/mnt/<drive>` mounts,
18
+ * everything else passes through verbatim.
19
+ *
20
+ * @param value - the value to translate.
21
+ * @returns the distro-side spelling.
22
+ */
23
+ export function distroPath(value) {
24
+ if (typeof value !== "string") return value;
25
+ if (isWslUnc(value)) {
26
+ const parsed = uncToPosix(value);
27
+ return parsed === undefined ? value : parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
28
+ }
29
+ const mounted = windowsToLinuxMount(value);
30
+ return mounted === undefined ? value : mounted;
31
+ }
32
+
33
+ /**
34
+ * Whether a workspace root belongs to a distro (and therefore routes
35
+ * distro-side).
36
+ *
37
+ * @param workspaceRoot - the scope's workspace root, any coordinate system.
38
+ * @returns true for a `\\wsl.localhost\<distro>` UNC root.
39
+ */
40
+ export function isDistroWorkspace(workspaceRoot) {
41
+ return isWslUnc(workspaceRoot);
42
+ }
43
+
44
+ /**
45
+ * The Linux working path for one request: UNC absolutes map onto their Linux
46
+ * paths, POSIX absolutes and relative paths join onto the root.
47
+ *
48
+ * @param linuxRoot - the workspace root in Linux form.
49
+ * @param path - the requested path, any coordinate system.
50
+ * @returns the absolute Linux path.
51
+ */
52
+ export function linuxJoin(linuxRoot, path) {
53
+ // The UNC check runs on the RAW spelling: the backslash-to-slash rewrite
54
+ // below would leave `//wsl.localhost/...`, which the identity regex cannot
55
+ // see.
56
+ const parsed = uncToPosix(path);
57
+ if (parsed !== undefined) return parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
58
+ const text = String(path).replaceAll("\\", "/");
59
+ if (text.startsWith("/")) return text.length > 1 ? text.replace(/\/+$/, "") : "/";
60
+ const base = linuxRoot === "/" ? "" : linuxRoot;
61
+ const joined = text.length === 0 ? base : `${base}/${text}`.replace(/\/\.(?=\/|$)/gu, "");
62
+ return joined.length === 0 ? "/" : joined;
63
+ }
64
+
65
+ /** The `stat` format string: kind, size, device, inode, mtimes — tab-joined. */
66
+ const STAT_FORMAT = "%F\t%s\t%D\t%i\t%Y\t%Z";
67
+
68
+ /**
69
+ * The record invocation for one path: line 1 is the no-follow kind (the
70
+ * upstream lstat gate), line 2 the follow-stat fields (the upstream final
71
+ * stat), tab-joined.
72
+ *
73
+ * @param file - absolute Linux path.
74
+ * @returns the agent argv.
75
+ */
76
+ export function statRecordArgv(file) {
77
+ const script = [
78
+ 'k=$(stat -c %F -- "$1") || exit 2',
79
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
80
+ "printf '%s\\t%s\\n' \"$k\" \"$s\"",
81
+ ].join("\n");
82
+ return ["sh", "-c", script, "sh", file];
83
+ }
84
+
85
+ /**
86
+ * Parse one stat record (kind + follow fields, tab-joined).
87
+ *
88
+ * @param line - the record line.
89
+ * @returns the upstream kind spelling, the byte size, and the synthesized
90
+ * opaque version.
91
+ */
92
+ export function parseStatRecord(line) {
93
+ const [kind, size, dev, ino, mtime, ctime] = String(line).trim().split("\t");
94
+ return {
95
+ kind,
96
+ type: wireKind(kind),
97
+ size: Number(size),
98
+ version: `${dev}:${ino}:${size}:${mtime}:${ctime}`,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Map a `stat` kind onto the wire's kind token.
104
+ *
105
+ * @param kind - the `stat -c %F` spelling.
106
+ * @returns the wire kind token.
107
+ */
108
+ export function wireKind(kind) {
109
+ const mapped = { "regular file": "file", directory: "directory", "symbolic link": "symlink" };
110
+ return mapped[kind] ?? kind;
111
+ }
112
+
113
+ /**
114
+ * The read invocation for one line-paged text read: the stat record rides
115
+ * stderr (the caller builds the stat half of the response), the page rides
116
+ * stdout — one line beyond the page, so EOF is decidable from the yield.
117
+ * `$1` is the file, `$2` the 1-based first line, `$3` the line count.
118
+ *
119
+ * @param file - absolute Linux path.
120
+ * @param offset - first line to return (1-based).
121
+ * @param limit - maximum lines to return.
122
+ * @returns the agent argv.
123
+ */
124
+ export function pageArgv(file, offset, limit) {
125
+ const script = [
126
+ 'k=$(stat -c %F -- "$1") || exit 2',
127
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
128
+ "printf '%s\\t%s\\n' \"$k\" \"$s\" >&2",
129
+ 'sed -n "${2},$(( $2 + $3 ))p" -- "$1"',
130
+ ].join("\n");
131
+ return ["sh", "-c", script, "sh", file, String(offset), String(limit)];
132
+ }
133
+
134
+ /**
135
+ * The byte-window invocation: the stat record rides stderr, the window rides
136
+ * stdout. `$1` is the file, `$2` the byte offset, `$3` the byte count.
137
+ *
138
+ * @param file - absolute Linux path.
139
+ * @param offset - byte offset (0-based).
140
+ * @param length - byte count.
141
+ * @returns the agent argv.
142
+ */
143
+ export function bytesArgv(file, offset, length) {
144
+ const script = [
145
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
146
+ "printf '%s\\n' \"$s\" >&2",
147
+ 'tail -c "+$(( $2 + 1 ))" -- "$1" | head -c "$3"',
148
+ ].join("\n");
149
+ return ["sh", "-c", script, "sh", file, String(offset), String(length)];
150
+ }
151
+
152
+ /**
153
+ * Parse the stat record a read or byte-window relayed on stderr.
154
+ *
155
+ * @param stderr - the relay's raw stderr.
156
+ * @returns the parsed stat record, or undefined when stderr carries none.
157
+ */
158
+ export function parseStatRecordFromStderr(stderr) {
159
+ const line = String(stderr)
160
+ .split("\n")
161
+ .find((candidate) => candidate.includes("\t"));
162
+ return line === undefined ? undefined : parseStatRecord(line);
163
+ }
164
+
165
+ /**
166
+ * The listing invocation for one directory: resolve the final component
167
+ * through symlinks, refuse paths outside the root and non-directories, then
168
+ * list one more entry than the cap so truncation is decidable from the yield.
169
+ * `-L` matches the upstream child semantics (a symlink to a directory lists
170
+ * as a directory, a broken link drops out); `-p` marks directories with the
171
+ * trailing `/` the parser keys on.
172
+ *
173
+ * @param linuxRoot - the workspace root in Linux form (containment boundary).
174
+ * @param linuxDir - the directory to list.
175
+ * @param maxEntries - the caller's entry cap.
176
+ * @returns the agent argv.
177
+ */
178
+ export function listDistroArgv(linuxRoot, linuxDir, maxEntries) {
179
+ const script = [
180
+ "root=$1 dir=$2 max=$3",
181
+ 'real=$(realpath -L -- "$dir") || exit 2',
182
+ 'case "$real" in "$root") ;; "$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac',
183
+ '[ -d "$real" ] || { echo "NOTDIR:$real" >&2; exit 4; }',
184
+ 'ls -1ALp -- "$real" | head -n "$max"',
185
+ ].join("\n");
186
+ return ["sh", "-c", script, "sh", linuxRoot, linuxDir, maxEntries + 1];
187
+ }
188
+
189
+ /**
190
+ * Parse one capped `ls -1ALp` listing into wire entries.
191
+ *
192
+ * @param stdout - the listing's raw stdout.
193
+ * @param maxEntries - the entry cap the caller configured.
194
+ * @returns at most `maxEntries` entries and whether the level had more.
195
+ */
196
+ export function parseDirListing(stdout, maxEntries) {
197
+ const lines = String(stdout).split("\n").filter((line) => line.length > 0);
198
+ const truncated = lines.length > maxEntries;
199
+ const entries = lines.slice(0, maxEntries).map((line) => {
200
+ const directory = line.endsWith("/");
201
+ const name = directory ? line.slice(0, -1) : line;
202
+ return { name, type: directory ? "directory" : "file" };
203
+ });
204
+ return { entries, truncated };
205
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * The distro-routed `workspace-files` service: the GUI file tree and previews
3
+ * for a `\\wsl.localhost\<distro>` workspace are served from inside the distro
4
+ * through the resident agent, instead of Windows-side 9P walks.
5
+ *
6
+ * ## Route predicate
7
+ *
8
+ * Every override checks `scope.workspaceRoot` — a distro UNC routes
9
+ * distro-side, a drive path calls `super` (host native, unchanged).
10
+ * `changes` (the watch stream) always delegates to `super`: its feed rides
11
+ * `ctx.fs.watch`, whose behaviour is today's on both workspace kinds.
12
+ *
13
+ * ## What the distro side reproduces
14
+ *
15
+ * The wire shapes of `dsh-api-workspace-files`: listings capped with a
16
+ * `truncated` flag, stats of `{absolutePath, version, bytes?}`, line-paged
17
+ * text with NUL and UTF-8 refusal and the byte-cap error, and byte windows
18
+ * with EOF markers. Versions are synthesized from `stat` fields — opaque to
19
+ * the client, which only compares them. `absolutePath` is synthesized in the
20
+ * UNC form so the GUI's path display stays what it was on the host fs. Read
21
+ * refuses a final-component symlink exactly like the upstream `locateFile`
22
+ * lstat gate does; `list` follows one (matching its own symlink-through
23
+ * rule).
24
+ *
25
+ * The distro operations are standalone functions over an injected runner, so
26
+ * they are unit-testable with a fake agent; the class methods are thin
27
+ * coordinate guards around them.
28
+ *
29
+ * @module dsh-plugin-wsl/workspace-files-wsl
30
+ */
31
+
32
+ import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
33
+ import { WorkspaceFiles } from "@deepseek-ai/dsh-api-workspace-files";
34
+ import { isDistroWorkspace, linuxJoin, listDistroArgv, parseDirListing, parseStatRecord, parseStatRecordFromStderr, pageArgv, statRecordArgv, bytesArgv } from "./workspace-files-route.js";
35
+ import { posixToUnc, uncToPosix } from "./paths.js";
36
+ import { sharedAgent } from "./agent-shared.js";
37
+
38
+ /** Throw the wire's not-found refusal. */
39
+ function notFound(path) {
40
+ return new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path });
41
+ }
42
+
43
+ /** Throw the wire's outside-workspace refusal. */
44
+ function outsideWorkspace(path) {
45
+ return new RemoteError("workspace-file/outside-workspace", `"${path}" is outside the workspace`, { path });
46
+ }
47
+
48
+ /** Throw the wire's not-directory refusal. */
49
+ function notDirectory(path, kind) {
50
+ return new RemoteError("workspace-file/not-directory", `"${path}" is a ${kind}`, { path, kind });
51
+ }
52
+
53
+ /** Throw the wire's not-regular-file refusal. */
54
+ function notRegularFile(path, kind) {
55
+ return new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${kind}`, { path, kind });
56
+ }
57
+
58
+ /** Throw the wire's too-large refusal. */
59
+ function tooLarge(path, limit) {
60
+ return new RemoteError("workspace-file/too-large", `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit });
61
+ }
62
+
63
+ /** Throw the wire's not-text refusal. */
64
+ function notText(path) {
65
+ return new RemoteError("workspace-file/not-text", `"${path}" is not UTF-8 text`, { path });
66
+ }
67
+
68
+ /**
69
+ * List one distro directory through the runner.
70
+ *
71
+ * The agent argv confines the directory under the workspace root, refuses
72
+ * non-directories, and emits one line per child — a trailing `/` marks a
73
+ * directory (symlinks resolve through, broken links drop out, matching the
74
+ * upstream child semantics).
75
+ *
76
+ * @param object - the listing's inputs.
77
+ * @param {object} object.runner - the agent-like runner (`exec`).
78
+ * @param {string} object.distro - the distro name.
79
+ * @param {string} object.linuxRoot - the workspace root in Linux form.
80
+ * @param {string} object.path - the requested directory, any coordinate system.
81
+ * @param {number} object.maxEntries - the entry cap.
82
+ * @param {AbortSignal} [object.signal] - caller cancellation.
83
+ * @returns {Promise<{path: string, entries: {name: string, type: string}[], truncated: boolean}>}
84
+ * the listing, with `path` relative to the workspace root (empty for it).
85
+ */
86
+ export async function distroList({ runner, distro, linuxRoot, path, maxEntries, signal }) {
87
+ const linuxDir = linuxJoin(linuxRoot, path);
88
+ const result = await runner.exec({
89
+ cwd: "/",
90
+ argv: listDistroArgv(linuxRoot, linuxDir, maxEntries),
91
+ maxOutputBytes: 4_000_000,
92
+ timeoutMs: 30_000,
93
+ signal,
94
+ });
95
+ if (result.exitCode === 3) throw outsideWorkspace(path);
96
+ if (result.exitCode === 4) throw notDirectory(path, "directory");
97
+ if (result.exitCode === 2) throw notFound(path);
98
+ if (result.exitCode !== 0) throw new Error(`listing "${linuxDir}" failed: ${String(result.stderr).trim() || `exit ${result.exitCode}`}`);
99
+ const { entries, truncated } = parseDirListing(result.stdout, maxEntries);
100
+ const relative = linuxDir === linuxRoot ? "" : linuxDir.slice(linuxRoot.length + 1);
101
+ return { path: relative, entries, truncated };
102
+ }
103
+
104
+ /**
105
+ * Stat one distro path through the runner.
106
+ *
107
+ * @param object - the stat's inputs.
108
+ * @param {object} object.runner - the agent-like runner (`exec`).
109
+ * @param {string} object.distro - the distro name.
110
+ * @param {string} object.linuxRoot - the workspace root in Linux form.
111
+ * @param {string} object.path - the requested path, any coordinate system.
112
+ * @param {string} object.distroWorkspaceRoot - the UNC workspace root, for the
113
+ * absolutePath display form.
114
+ * @param {AbortSignal} [object.signal] - caller cancellation.
115
+ * @returns {Promise<{absolutePath: string, version: string, bytes?: number}>}
116
+ */
117
+ export async function distroStat({ runner, distro, linuxRoot, path, distroWorkspaceRoot, signal }) {
118
+ const linuxFile = linuxJoin(linuxRoot, path);
119
+ const result = await runner.exec({
120
+ cwd: "/",
121
+ argv: statRecordArgv(linuxFile),
122
+ maxOutputBytes: 4096,
123
+ timeoutMs: 30_000,
124
+ signal,
125
+ });
126
+ if (result.exitCode !== 0) throw notFound(path);
127
+ const record = parseStatRecord(result.stdout);
128
+ const stat = { absolutePath: posixToUnc(distro, linuxFile), version: record.version };
129
+ return record.size === undefined || Number.isNaN(record.size) ? stat : { ...stat, bytes: record.size };
130
+ }
131
+
132
+ /**
133
+ * Read one line-paged UTF-8 window through the runner, mirroring the
134
+ * upstream `cutPage` semantics: a NUL byte marks the page binary, a page
135
+ * above the byte cap is refused (never shortened), and `eof` is false exactly
136
+ * when a character exists past the page.
137
+ *
138
+ * @param object - the read's inputs.
139
+ * @param {object} object.runner - the agent-like runner (`exec`).
140
+ * @param {string} object.distro - the distro name.
141
+ * @param {string} object.linuxRoot - the workspace root in Linux form.
142
+ * @param {string} object.path - the requested path, any coordinate system.
143
+ * @param {number} object.offset - first line to return (1-based).
144
+ * @param {number} object.limit - maximum lines to return.
145
+ * @param {number} object.maxBytes - the page's byte cap.
146
+ * @param {string} object.distroWorkspaceRoot - the UNC workspace root, for the
147
+ * absolutePath display form.
148
+ * @param {AbortSignal} [object.signal] - caller cancellation.
149
+ * @returns {Promise<{absolutePath: string, version: string, bytes?: number, offset: number, text: string, lines: number, eof: boolean}>}
150
+ */
151
+ export async function distroRead({ runner, distro, linuxRoot, path, offset, limit, maxBytes, distroWorkspaceRoot, signal }) {
152
+ const linuxFile = linuxJoin(linuxRoot, path);
153
+ const result = await runner.exec({
154
+ cwd: "/",
155
+ argv: pageArgv(linuxFile, offset, limit),
156
+ maxOutputBytes: (maxBytes + 1) * 4,
157
+ timeoutMs: 60_000,
158
+ signal,
159
+ });
160
+ if (result.exitCode === 2) throw notFound(path);
161
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
162
+ throw new Error(`reading "${linuxFile}" failed: ${String(result.stderr).trim() || `exit ${result.exitCode}`}`);
163
+ }
164
+ const record = parseStatRecordFromStderr(result.stderr);
165
+ if (record === undefined) throw notFound(path);
166
+ if (record.type !== "file") throw notRegularFile(path, record.type);
167
+ const raw = Buffer.from(result.stdout);
168
+ const strict = new TextDecoder("utf-8", { fatal: true });
169
+ let text;
170
+ try {
171
+ text = strict.decode(raw);
172
+ } catch {
173
+ throw notText(path);
174
+ }
175
+ const lines = text.length === 0 ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
176
+ const bytes = Buffer.byteLength(text, "utf8") + lines;
177
+ if (bytes > maxBytes) {
178
+ throw new RemoteError(
179
+ "workspace-file/too-large",
180
+ `lines ${offset}-${offset + limit - 1} of "${path}" exceed the ${maxBytes} byte cap`,
181
+ { path, limit: maxBytes },
182
+ );
183
+ }
184
+ if (text.includes(String.fromCharCode(0))) throw notText(path);
185
+ const eof = lines < limit;
186
+ const stat = { absolutePath: posixToUnc(distro, linuxFile), version: record.version };
187
+ return { ...stat, offset, text, lines, eof };
188
+ }
189
+
190
+ /**
191
+ * Read one byte window (or the capped whole file) through the runner.
192
+ *
193
+ * @param object - the read's inputs.
194
+ * @param {object} object.runner - the agent-like runner (`exec`).
195
+ * @param {string} object.distro - the distro name.
196
+ * @param {string} object.linuxRoot - the workspace root in Linux form.
197
+ * @param {string} object.path - the requested path, any coordinate system.
198
+ * @param {number|undefined} object.offset - byte offset (0-based).
199
+ * @param {number|undefined} object.length - byte count.
200
+ * @param {number} object.maxBytes - the window cap.
201
+ * @param {number} object.maxFileBytes - the complete-file cap.
202
+ * @param {string} object.distroWorkspaceRoot - the UNC workspace root, for the
203
+ * absolutePath display form.
204
+ * @param {AbortSignal} [object.signal] - caller cancellation.
205
+ * @returns {Promise<{absolutePath: string, version: string, bytes?: number, offset: number, data: Uint8Array, eof: boolean}>}
206
+ */
207
+ export async function distroReadBytes({ runner, distro, linuxRoot, path, offset, length, maxFileBytes, distroWorkspaceRoot, signal }) {
208
+ const linuxFile = linuxJoin(linuxRoot, path);
209
+ // One exec carries both halves: the stat record on stderr (for the version,
210
+ // the size, and the whole-file refusal), the window on stdout.
211
+ const window = offset === undefined && length === undefined
212
+ ? { offset: 0, length: maxFileBytes }
213
+ : { offset: offset ?? 0, length: length ?? maxFileBytes };
214
+ const result = await runner.exec({
215
+ cwd: "/",
216
+ argv: bytesArgv(linuxFile, window.offset, window.length),
217
+ maxOutputBytes: window.length + 1024,
218
+ timeoutMs: 60_000,
219
+ signal,
220
+ });
221
+ const record = parseStatRecordFromStderr(result.stderr);
222
+ if (record === undefined) throw notFound(path);
223
+ if (record.type !== "file") throw notRegularFile(path, record.type);
224
+ const data = new Uint8Array(Buffer.from(result.stdout).subarray(0, window.length));
225
+ // The whole-file form mirrors the upstream refusal: a file bigger than the
226
+ // cap is refused, never silently truncated (the stderr record names the
227
+ // real size even though head capped the stdout).
228
+ if (offset === undefined && length === undefined && record.size > maxFileBytes) {
229
+ throw tooLarge(path, maxFileBytes);
230
+ }
231
+ return {
232
+ absolutePath: posixToUnc(distro, linuxFile),
233
+ version: record.version,
234
+ offset: window.offset,
235
+ data,
236
+ eof: window.offset + data.length >= record.size,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * The distro-routed `workspace-files` service.
242
+ */
243
+ export class WorkspaceFilesWsl extends WorkspaceFiles {
244
+ static Config = WorkspaceFiles.Config;
245
+
246
+ /** The agent runner for one distro; a hook tests substitute. */
247
+ runnerFor(distro) {
248
+ return sharedAgent(distro);
249
+ }
250
+
251
+ /** The distro coordinates of one workspace scope. */
252
+ coords(workspaceRoot) {
253
+ const parsed = uncToPosix(workspaceRoot);
254
+ return { distro: parsed.distro, linuxRoot: parsed.linuxPath.length > 0 ? parsed.linuxPath : "/" };
255
+ }
256
+
257
+ async list(workspaceFileScope, path, signal) {
258
+ if (!isDistroWorkspace(workspaceFileScope.workspaceRoot)) return super.list(workspaceFileScope, path, signal);
259
+ const { distro, linuxRoot } = this.coords(workspaceFileScope.workspaceRoot);
260
+ const { path: relative, entries, truncated } = await distroList({
261
+ runner: this.runnerFor(distro),
262
+ distro,
263
+ linuxRoot,
264
+ path,
265
+ maxEntries: this.config.maxEntries,
266
+ signal,
267
+ });
268
+ return {
269
+ path: relative,
270
+ entries: entries.map((entry) => entry.size === undefined ? entry : { ...entry, size: entry.size }),
271
+ truncated,
272
+ };
273
+ }
274
+
275
+ async stat(workspaceFileScope, path, signal) {
276
+ if (!isDistroWorkspace(workspaceFileScope.workspaceRoot)) return super.stat(workspaceFileScope, path, signal);
277
+ const { distro, linuxRoot } = this.coords(workspaceFileScope.workspaceRoot);
278
+ return distroStat({ runner: this.runnerFor(distro), distro, linuxRoot, path, distroWorkspaceRoot: workspaceFileScope.workspaceRoot, signal });
279
+ }
280
+
281
+ async read(workspaceFileScope, path, range, signal) {
282
+ if (!isDistroWorkspace(workspaceFileScope.workspaceRoot)) return super.read(workspaceFileScope, path, range, signal);
283
+ const { distro, linuxRoot } = this.coords(workspaceFileScope.workspaceRoot);
284
+ const offset = range.offset === undefined ? 1 : range.offset;
285
+ const limit = range.limit === undefined ? this.config.maxLines : range.limit;
286
+ if (!Number.isSafeInteger(offset) || offset < 1) {
287
+ throw new RemoteError("gateway/bad-request", "offset must be a safe integer of at least 1", {});
288
+ }
289
+ if (limit > this.config.maxLines) {
290
+ throw new RemoteError("gateway/bad-request", `limit must be at most ${this.config.maxLines}`, {});
291
+ }
292
+ return distroRead({
293
+ runner: this.runnerFor(distro),
294
+ distro,
295
+ linuxRoot,
296
+ path,
297
+ offset,
298
+ limit,
299
+ maxBytes: this.config.maxBytes,
300
+ distroWorkspaceRoot: workspaceFileScope.workspaceRoot,
301
+ signal,
302
+ });
303
+ }
304
+
305
+ async readBytes(workspaceFileScope, path, options, signal) {
306
+ if (!isDistroWorkspace(workspaceFileScope.workspaceRoot)) return super.readBytes(workspaceFileScope, path, options, signal);
307
+ const { distro, linuxRoot } = this.coords(workspaceFileScope.workspaceRoot);
308
+ return distroReadBytes({
309
+ runner: this.runnerFor(distro),
310
+ distro,
311
+ linuxRoot,
312
+ path,
313
+ offset: options.range?.offset,
314
+ length: options.range?.length,
315
+ maxBytes: this.config.maxBytes,
316
+ maxFileBytes: this.config.maxFileBytes,
317
+ path,
318
+ distroWorkspaceRoot: workspaceFileScope.workspaceRoot,
319
+ signal,
320
+ });
321
+ }
322
+
323
+ async readByteRange(workspaceFileScope, path, offset, length, signal) {
324
+ if (!isDistroWorkspace(workspaceFileScope.workspaceRoot)) return super.readByteRange(workspaceFileScope, path, offset, length, signal);
325
+ const { distro, linuxRoot } = this.coords(workspaceFileScope.workspaceRoot);
326
+ return distroReadBytes({
327
+ runner: this.runnerFor(distro),
328
+ distro,
329
+ linuxRoot,
330
+ path,
331
+ offset,
332
+ length,
333
+ maxBytes: length,
334
+ maxFileBytes: Number.MAX_SAFE_INTEGER,
335
+ distroWorkspaceRoot: workspaceFileScope.workspaceRoot,
336
+ signal,
337
+ });
338
+ }
339
+ }
340
+
341
+ export default WorkspaceFilesWsl;
package/lib/wsl.js CHANGED
@@ -15,6 +15,18 @@ import { posixToUnc } from "./paths.js";
15
15
  /** Default executable name; resolution is left to PATH. */
16
16
  export const DEFAULT_WSL_PATH = "wsl.exe";
17
17
 
18
+ /**
19
+ * The deadline every `wsl.exe` call runs under, when the caller names none.
20
+ *
21
+ * A resolution call that never returns is worse than a failed one: `wsl.exe`
22
+ * talks to the vmcompute service, and a wedged service would hang the tool
23
+ * call forever — no timeout, no error, a silent dead session. Every call now
24
+ * dies loudly instead. The bound is deliberately generous (a cold distro boot
25
+ * on a slow disk is tens of seconds), and `0` disables it for a caller that
26
+ * genuinely wants to wait forever.
27
+ */
28
+ export const DEFAULT_WSL_DEADLINE_MS = 60_000;
29
+
18
30
  /**
19
31
  * Run one host command and capture UTF-8 output.
20
32
  *
@@ -24,16 +36,18 @@ export const DEFAULT_WSL_PATH = "wsl.exe";
24
36
  *
25
37
  * @param argv - executable plus arguments, spawned without a shell.
26
38
  * @param signal - optional cancellation.
39
+ * @param deadlineMs - kill the call after this many milliseconds; the default
40
+ * is {@link DEFAULT_WSL_DEADLINE_MS}, and `0` waits forever.
27
41
  * @returns captured stdout.
28
42
  * @throws the execFile error, with a hint attached when the cause is the sandbox.
29
43
  */
30
- export function runCapture(argv, signal) {
44
+ export function runCapture(argv, signal, deadlineMs = DEFAULT_WSL_DEADLINE_MS) {
31
45
  const [file, ...args] = argv;
32
46
  return new Promise((resolve, reject) => {
33
47
  execFile(
34
48
  file,
35
49
  args,
36
- { env: { ...process.env, WSL_UTF8: "1" }, windowsHide: true, signal, encoding: "utf8" },
50
+ { env: { ...process.env, WSL_UTF8: "1" }, windowsHide: true, signal, encoding: "utf8", timeout: deadlineMs },
37
51
  (error, stdout, stderr) => {
38
52
  if (error) {
39
53
  // A denied WSL call is the likeliest failure here and its own message
@@ -41,6 +55,10 @@ export function runCapture(argv, signal) {
41
55
  const detail = `${stdout ?? ""}${stderr ?? ""}`.replace(/\u0000/g, "").trim();
42
56
  const denied = /E_ACCESSDENIED/i.test(detail) || error.code === "EACCES";
43
57
  const missing = error.code === "ENOENT";
58
+ // The deadline kill arrives as a SIGTERM with no WSL diagnostic, so
59
+ // it must be named for what it is — not left looking like WSL's own
60
+ // failure. An abort is cancellation, not a deadline.
61
+ const stuck = error.killed === true && error.name !== "AbortError";
44
62
  error.message = `${file} failed: ${detail || error.message}${
45
63
  denied
46
64
  ? " (WSL is unreachable from a sandboxed process: DSH's Windows ACL sandbox runs commands under a restricted low-integrity token. Compose the non-sandboxing providers.)"
@@ -49,6 +67,10 @@ export function runCapture(argv, signal) {
49
67
  missing
50
68
  ? ` (the executable "${file}" could not be started: check \`wslPath\` in this row, and that WSL is installed)`
51
69
  : ""
70
+ }${
71
+ stuck
72
+ ? ` (no completion within ${deadlineMs}ms — the WSL service may be wedged; retry once, then \`wsl.exe --shutdown\` and reopen)`
73
+ : ""
52
74
  }`;
53
75
  reject(error);
54
76
  return;
@@ -136,45 +158,6 @@ export async function linuxHome(distro, options = {}) {
136
158
  * @returns the canonical absolute Linux path, or undefined when it cannot be
137
159
  * determined (missing file, broken link, or a failed query).
138
160
  */
139
- export async function canonicalLinuxPath(distro, linuxPath, options = {}) {
140
- try {
141
- const stdout = await runCapture(
142
- [options.wslPath ?? DEFAULT_WSL_PATH, "-d", distro, "--exec", "readlink", "-f", linuxPath],
143
- options.signal,
144
- );
145
- const resolved = stdout.trim();
146
- return resolved.startsWith("/") ? resolved : undefined;
147
- } catch {
148
- return undefined;
149
- }
150
- }
151
-
152
- /**
153
- * Give `targetLinux` the same POSIX mode `sourceLinux` has, inside the distro.
154
- *
155
- * The UNC share does not carry POSIX modes to the host: a host-side `chmod` on a
156
- * share path is silently ignored, and a host-side `stat` reports 0666 no matter
157
- * what the distro sees. The distro itself has the real mode, so the mode has to
158
- * be applied there — and because a rename preserves the inode's mode, applying
159
- * it to a staged temp file *before* publication is enough for the published file
160
- * to come out with the right bits.
161
- *
162
- * `stat` + `chmod` rather than `chmod --reference` keeps this working on distros
163
- * whose chmod is busybox's.
164
- *
165
- * @param distro - distro name.
166
- * @param sourceLinux - absolute Linux path whose mode is copied.
167
- * @param targetLinux - absolute Linux path that receives the mode.
168
- * @param options - the `wsl.exe` path and optional cancellation.
169
- * @throws when the mode cannot be applied.
170
- */
171
- export async function copyModeInDistro(distro, sourceLinux, targetLinux, options = {}) {
172
- const script = 'if [ -e "$1" ]; then mode=$(stat -c %a "$1") && chmod "$mode" "$2"; fi';
173
- await runCapture(
174
- [options.wslPath ?? DEFAULT_WSL_PATH, "-d", distro, "--exec", "sh", "-c", script, "sh", sourceLinux, targetLinux],
175
- options.signal,
176
- );
177
- }
178
161
 
179
162
  /**
180
163
  * Shells known to accept the combined POSIX `-lc` login-plus-command form.