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.
- package/CHANGELOG.md +176 -0
- package/README.md +6 -6
- 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/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 +22 -3
- package/lib/fs-publish.js +0 -42
|
@@ -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;
|