dsh-plugin-wsl-env 0.6.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,121 @@
1
+ /**
2
+ * The distro-side activity observation for GUI distro terminals, and the
3
+ * handle wrapper that installs it.
4
+ *
5
+ * ## The gap this closes
6
+ *
7
+ * The terminal controller reclaims unattended terminals after
8
+ * `unattendedTimeoutMs` of observed idleness, but its observation is
9
+ * host-side: `dsh-subprocess-local`'s shell-activity integration is gated to
10
+ * interactive `bash`/`zsh` launched directly on a POSIX host, and a distro
11
+ * terminal is `wsl.exe` on win32. Every branch of the shipped
12
+ * `inspectActivity` therefore settles on `unknown` for these terminals, and
13
+ * `unknown` never accumulates idle — a distro terminal had to be closed by
14
+ * hand.
15
+ *
16
+ * ## The observation
17
+ *
18
+ * The terminal is MARKED at launch: a per-terminal `DSH_TERMINAL_ID` rides
19
+ * the `WSLENV` forwarding this provider already does, so the distro shell —
20
+ * and every process it spawns — carries it in `/proc/<pid>/environ`. The
21
+ * controller's 30 s retention poll then asks the resident agent to count the
22
+ * marked processes: exactly one (the shell alone, at its prompt) is IDLE;
23
+ * more than one (a command, a pipeline, a nested shell is running) is BUSY;
24
+ * none at all defers to the shipped handle (the shell has exited; its own
25
+ * exit bookkeeping is more precise than this scan). The agent being out
26
+ * yields `unknown` — the same never-reclaim posture as before, which is the
27
+ * safe direction: a probe that cannot answer must not authorize a close.
28
+ *
29
+ * Pure builders and a transparent proxy here; the agent wiring lives in the
30
+ * subprocess provider.
31
+ *
32
+ * @module dsh-plugin-wsl/terminal-activity
33
+ */
34
+
35
+ /** The env name that marks one terminal's process tree inside the distro. */
36
+ export const TERMINAL_ID_ENV = "DSH_TERMINAL_ID";
37
+
38
+ /**
39
+ * The seam's activity observation — structurally the upstream
40
+ * `SubprocessTerminalActivity` (`subprocess-local/src/terminal.ts`): `state`
41
+ * is `'idle' | 'busy' | 'unknown'` and `revision` is a counter that changes
42
+ * when the reported state changes, which is what the controller's retention
43
+ * loop keys its idle accumulation on.
44
+ *
45
+ * @typedef {Object} SubprocessTerminalActivity
46
+ * @property {'idle' | 'busy' | 'unknown'} state
47
+ * @property {number} revision
48
+ */
49
+
50
+ /**
51
+ * The probe: count the `/proc` processes carrying the terminal marker.
52
+ *
53
+ * POSIX sh on purpose (the distro's `sh` is the only guaranteed interpreter).
54
+ * The count is the whole decision: the terminal's login shell is exec'd WITH
55
+ * the marker (wsl.exe imports it into the session before `--exec`), so an
56
+ * idle-at-prompt shell is exactly one marked process, and anything the shell
57
+ * runs — a command, a pipeline, a nested shell — is exec'd with the marker
58
+ * inherited, pushing the count past one. `/proc/<pid>/environ` shows the
59
+ * exec-time environment, which is precisely why the count works: runtime
60
+ * exports would be invisible, and exec-time inheritance is the signal.
61
+ *
62
+ * The marker check reads the NUL-separated environ through `tr`, which is why
63
+ * the id is passed as `$1` rather than interpolated into the script.
64
+ *
65
+ * @param terminalId - the `DSH_TERMINAL_ID` value one terminal was launched with.
66
+ * @returns the agent `exec` target: the probe script with the id as `$1`.
67
+ */
68
+ export function terminalActivityProbe(terminalId) {
69
+ const script = [
70
+ "id=$1",
71
+ "count=0",
72
+ 'for d in /proc/[0-9]*; do',
73
+ ' [ -r "$d/environ" ] || continue',
74
+ ' if tr "\\000" "\\n" < "$d/environ" 2>/dev/null | grep -qx "DSH_TERMINAL_ID=$id"; then',
75
+ " count=$((count+1))",
76
+ ' [ "$count" -gt 1 ] && { echo busy; exit 0; }',
77
+ " fi",
78
+ "done",
79
+ "echo idle",
80
+ ].join("\n");
81
+ return { cwd: "/", argv: ["sh", "-c", script, "sh", terminalId] };
82
+ }
83
+
84
+ /**
85
+ * Parse the probe's stdout into an activity state.
86
+ *
87
+ * @param stdout - the probe's raw stdout.
88
+ * @returns `"busy"` or `"idle"` as the probe reported, or undefined when the
89
+ * output is anything else (the caller answers `unknown`).
90
+ */
91
+ export function parseTerminalActivity(stdout) {
92
+ const text = String(stdout).trim();
93
+ if (text === "busy" || text === "idle") return text;
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Wrap a terminal handle so its activity is observed inside the distro.
99
+ *
100
+ * Everything except `inspectActivity` forwards to the wrapped handle
101
+ * transparently — methods stay bound to the real handle, so the controller's
102
+ * `write`/`resize`/`signalForeground`/stream access behave exactly as on the
103
+ * shipped object. `inspectActivity` is replaced wholesale: the shipped
104
+ * implementation can only answer `unknown` for a distro terminal, and the
105
+ * replacement answers from the marker scan (or `unknown` itself, when the
106
+ * probe cannot answer).
107
+ *
108
+ * @param handle - the handle `spawnTerminal` returned.
109
+ * @param inspectActivity - the replacement observation, matching the seam's
110
+ * `() => Promise<SubprocessTerminalActivity>` shape.
111
+ * @returns the transparent proxy handle.
112
+ */
113
+ export function wrapTerminalHandle(handle, inspectActivity) {
114
+ return new Proxy(handle, {
115
+ get(target, prop) {
116
+ if (prop === "inspectActivity") return inspectActivity;
117
+ const value = Reflect.get(target, prop);
118
+ return typeof value === "function" ? value.bind(target) : value;
119
+ },
120
+ });
121
+ }
@@ -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;