dsh-plugin-wsl-env 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +417 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +84 -68
  4. package/SUPPORT.md +3 -3
  5. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  6. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  7. package/agent/wsl-agent.sh +83 -16
  8. package/cordis.patch.yml +41 -35
  9. package/docs/ARCHITECTURE.md +9 -1
  10. package/docs/CONFIGURATION.md +7 -4
  11. package/docs/LIMITATIONS.md +53 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +97 -74
  15. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  16. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  17. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  18. package/lib/agent-protocol.d.ts +10 -4
  19. package/lib/agent-protocol.js +10 -3
  20. package/lib/agent.d.ts +84 -0
  21. package/lib/agent.js +167 -15
  22. package/lib/bwrap.d.ts +8 -0
  23. package/lib/bwrap.js +4 -1
  24. package/lib/fs-routing.d.ts +40 -4
  25. package/lib/fs-routing.js +80 -12
  26. package/lib/fsio-agent.js +1 -1
  27. package/lib/index.d.ts +134 -0
  28. package/lib/index.js +191 -3
  29. package/lib/native-agent.d.ts +124 -0
  30. package/lib/native-agent.js +198 -0
  31. package/lib/ports.d.ts +13 -2
  32. package/lib/ports.js +18 -5
  33. package/lib/preflight.js +24 -1
  34. package/lib/session-runtime.d.ts +161 -0
  35. package/lib/session-runtime.js +220 -0
  36. package/lib/session-scope.d.ts +124 -0
  37. package/lib/session-scope.js +191 -0
  38. package/lib/shell-env.js +10 -3
  39. package/lib/watcher.d.ts +25 -12
  40. package/lib/watcher.js +120 -32
  41. package/lib/workspace-files-route.d.ts +8 -4
  42. package/lib/workspace-files-route.js +42 -14
  43. package/lib/workspace-files-wsl.js +25 -8
  44. package/package.json +34 -25
package/lib/watcher.js CHANGED
@@ -8,21 +8,33 @@
8
8
  * whenever something under `target` may have changed. That is cheap to honor
9
9
  * from inside the distro, where the kernel does know.
10
10
  *
11
- * The watcher is a long-lived `wsl.exe` shell loop: periodically
11
+ * When `inotifywait` is installed and the target is an unbounded directory,
12
+ * the watcher uses inotify: recursive kernel watches, one `C` per 0.5s of
13
+ * events (a build's event storm is coalesced, the first event is immediate).
14
+ * Otherwise it falls back to the long-lived polling loop: every cadence,
12
15
  * `find <dir> -newer <stamp>` — the directory itself is in the scan, so
13
16
  * creations AND deletions (which bump the parent's mtime) are both seen — and
14
- * one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
15
- * for interactive-paced edits, which is the only pace a session watch serves.
17
+ * one `C` line per hit. A FILE target uses the `mtime:size` fingerprint (so a
18
+ * rename-over or an in-place write is seen), and a positive `maxDepth` keeps
19
+ * the polling scan because inotify cannot bound its own walk.
16
20
  *
17
21
  * The loop speaks three one-letter lines, the watcher protocol:
18
22
  *
19
23
  * - `R` — READY: the activation barrier; the host resolves the watch promise
20
24
  * on it, never before.
21
- * - `C` — CHANGED: something under the target may have changed.
22
- * - `E` — ERROR: the watched directory no longer exists, and the loop has
25
+ * - `C` — CHANGED: the target may have changed.
26
+ * - `E` — ERROR: the watched path no longer exists, and the loop has
23
27
  * exited. Without it a vanished target would sit armed forever — `find`
24
28
  * over a missing directory prints nothing, indistinguishable from "no
25
29
  * changes" — so the loop says so and the host reports it.
30
+ * - `P <pid>` — the loop shell's PID, sent before READY. The host uses it to
31
+ * stop the loop with a second `wsl.exe` on close: killing the transport
32
+ * alone does not reliably close the distro-side pipe.
33
+ * - `H` — HEARTBEAT: liveness only, ignored by the host. It exists so the
34
+ * loop notices a closed stdout pipe: the host's close kills `wsl.exe` but
35
+ * cannot signal the distro-side shell, and without a write that shell
36
+ * would sleep and poll forever. The `PIPE` trap turns that write's
37
+ * SIGPIPE into a clean exit through the stamp-removing EXIT trap.
26
38
  *
27
39
  * Known blind spot, accepted and documented: a file created with an mtime
28
40
  * older than the stamp (`cp -p`, `tar -x`) is not seen until something else
@@ -38,14 +50,15 @@ import { spawn } from "node:child_process";
38
50
  import { createInterface } from "node:readline";
39
51
  /**
40
52
  * The shell loop the watcher runs, parameterised by argv so no path can break
41
- * quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
42
- * Pure text, so it is unit-testable.
53
+ * quoting: `$1` is the Linux path (a directory or a file), `$2` the poll
54
+ * interval in seconds. Pure text, so it is unit-testable.
43
55
  *
44
56
  * @param intervalSeconds - poll cadence; must be >= 1.
45
- * @param maxDepth - bound the scan's depth, 1 = the directory
46
- * itself and its direct children; 0 (the default) scans the whole tree,
47
- * which is the contract's semantics but a real cost over a tree with a
48
- * `node_modules` in it.
57
+ * @param maxDepth - bound the scan's depth for a DIRECTORY target, 1 = the
58
+ * directory itself and its direct children; 0 (the default) scans the whole
59
+ * tree, which is the contract's semantics. Positive values select the
60
+ * polling scan (inotify is used only for the unbounded walk). A file target
61
+ * ignores it.
49
62
  * @returns the `sh -c` script body.
50
63
  */
51
64
  export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
@@ -56,37 +69,90 @@ export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
56
69
  const depthArgs = depth > 0 ? ` -maxdepth ${depth}` : "";
57
70
  // `find` lists the directory itself first: a child's creation or deletion
58
71
  // bumps the parent's mtime, so both edges are seen, not just modifications.
72
+ // A FILE target has no tree to walk; its `mtime:size` fingerprint answers the
73
+ // same one-way question ("may have changed") and covers a rename-over too,
74
+ // since the path is re-stat'ed each tick.
59
75
  return [
60
76
  'D="$1"',
61
77
  'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
78
+ 'FIFO="${TMPDIR:-/tmp}/wsl-watch.$$.fifo"',
79
+ 'IW=""',
62
80
  'touch "$S"',
63
- // The stamp is the loop's private state, so every way out removes it. The
64
- // EXIT trap covers plain exits; a signal alone would end the shell WITHOUT
65
- // running it, so TERM/INT/HUP are trapped to a plain exit first.
66
- "trap 'rm -f \"$S\"' EXIT",
67
- "trap 'exit 0' TERM INT HUP",
68
- // The READY line is the activation barrier: everything changed BEFORE it
69
- // predates the stamp, so the host must not resolve the watch promise until
70
- // it arrives — otherwise a write racing the first tick is invisible forever.
81
+ // The stamp/FIFO are the loop's private state, so every way out removes
82
+ // them. The EXIT trap covers plain exits; a signal alone would end the
83
+ // shell WITHOUT running it, so TERM/INT/HUP/PIPE are trapped to a plain
84
+ // exit first. `pkill -P $$` also reaps inotifywait/sleep/find.
85
+ "trap 'kill \"$IW\" 2>/dev/null; pkill -P $$ 2>/dev/null; exec 3>&- 2>/dev/null; rm -f \"$FIFO\" \"$S\" 2>/dev/null' EXIT",
86
+ "trap 'exit 0' TERM INT HUP PIPE",
87
+ 'F=""',
88
+ '[ -f "$D" ] && F=$(stat -c "%Y:%s" -- "$D" 2>/dev/null || :)',
89
+ // Prefer inotify for an unbounded DIRECTORY watch: events are immediate and
90
+ // the kernel does the walking. A file target uses the fingerprint loop
91
+ // (it also reports a removal as E), and a bounded scan (maxDepth > 0)
92
+ // keeps the polling loop, which honors the bound.
93
+ `if [ -d "$D" ] && [ ${depth} -eq 0 ] && command -v inotifywait >/dev/null 2>&1; then`,
94
+ ' if mkfifo "$FIFO" 2>/dev/null; then',
95
+ ' exec 3<>"$FIFO"',
96
+ ' inotifywait -m -q -r -e create,delete,modify,move,attrib --format "%e" "$D" >"$FIFO" 2>/dev/null &',
97
+ ' IW=$!',
98
+ ' fi',
99
+ 'fi',
100
+ // The PID precedes READY so a close can stop the loop even if the pipe
101
+ // stays open; killing wsl.exe alone does not close it reliably.
102
+ "printf 'P %s\\n' \"$$\"",
71
103
  "printf 'R\\n'",
72
- // Check first, sleep second: a change made right after activation is seen
73
- // on the first cadence, not one cadence later.
104
+ // One C per half second: a build's event storm becomes a coarse
105
+ // invalidation, while the first event is immediate. A quiet tree blocks
106
+ // in read; the close stops the shell through the P line, not the pipe.
107
+ 'if [ -n "$IW" ]; then',
108
+ // A one-second marker on the same FIFO wakes the blocking read so the
109
+ // TERM trap and the vanished-target check run even on a quiet tree. The
110
+ // marker is not an event: it is skipped, and only checked against the
111
+ // target's existence.
112
+ ' ( while :; do sleep 1; printf \'H\\n\' >"$FIFO" 2>/dev/null; done ) &',
113
+ ' while read -r ev <&3; do',
114
+ ' if [ "$ev" = "H" ]; then',
115
+ ' [ -d "$D" ] || { printf \'E\\n\'; exit 1; }',
116
+ ' continue',
117
+ ' fi',
118
+ ' [ -d "$D" ] || { printf \'E\\n\'; exit 1; }',
119
+ " printf 'C\\n'",
120
+ ' sleep 0.5',
121
+ ' done',
122
+ ' exec 3>&-',
123
+ 'fi',
124
+ // Polling fallback: no inotifywait, a bounded scan, or inotifywait exited
125
+ // (a vanished target or an inotify limit). The stamp is the baseline.
74
126
  "while :; do",
75
- ' if [ ! -d "$D" ]; then',
76
- // Say so and die: silence here is the armed-forever, fires-never trap.
127
+ ' if [ -d "$D" ]; then',
128
+ ` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
129
+ ' touch "$S"',
130
+ " printf 'C\\n'",
131
+ ' fi',
132
+ ' elif [ -f "$D" ]; then',
133
+ ' cur=$(stat -c "%Y:%s" -- "$D" 2>/dev/null || :)',
134
+ ' if [ -z "$cur" ]; then',
135
+ // The file was removed (or became unreadable): the target is gone.
136
+ " printf 'E\\n'",
137
+ ' exit 1',
138
+ ' fi',
139
+ ' if [ "$cur" != "$F" ]; then',
140
+ ' F="$cur"',
141
+ " printf 'C\\n'",
142
+ ' fi',
143
+ ' else',
144
+ // Neither a directory nor a file: say so and die, rather than spin.
77
145
  " printf 'E\\n'",
78
- " exit 1",
79
- " fi",
80
- ` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
81
- ' touch "$S"',
82
- " printf 'C\\n'",
83
- " fi",
146
+ ' exit 1',
147
+ ' fi',
148
+ // Liveness: the host ignores H; the write dies with the pipe.
149
+ " printf 'H\\n'",
84
150
  ` sleep ${seconds}`,
85
- "done",
151
+ 'done',
86
152
  ].join("\n");
87
153
  }
88
154
  /**
89
- * Arm one in-distro watcher over a Linux directory.
155
+ * Arm one in-distro watcher over a Linux directory or file.
90
156
  *
91
157
  * @param options - the watch request.
92
158
  * @returns resolves once observation is active, with the async close function
@@ -118,8 +184,15 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
118
184
  const detail = stderrTail.trim();
119
185
  return detail.length > 0 ? `${message}: ${detail}` : message;
120
186
  };
187
+ let loopPid;
121
188
  const lines = createInterface({ input: child.stdout });
122
189
  lines.on("line", (line) => {
190
+ if (line.startsWith("P ")) {
191
+ const pid = Number.parseInt(line.slice(2), 10);
192
+ if (Number.isInteger(pid) && pid > 0)
193
+ loopPid = pid;
194
+ return;
195
+ }
123
196
  if (line === "R") {
124
197
  if (!settled && !signal?.aborted) {
125
198
  settled = true;
@@ -128,7 +201,7 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
128
201
  return;
129
202
  }
130
203
  if (line === "E") {
131
- fail(new Error(describe(`the watched directory "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
204
+ fail(new Error(describe(`the watched path "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
132
205
  return;
133
206
  }
134
207
  if (line === "C") {
@@ -139,10 +212,25 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
139
212
  if (closed)
140
213
  return;
141
214
  closed = true;
215
+ const pid = loopPid;
142
216
  child.removeAllListeners("exit");
143
217
  lines.close();
144
218
  child.stdout?.destroy();
145
219
  child.kill();
220
+ if (pid !== undefined) {
221
+ // A second wsl.exe reaches the same distro namespace; the loop's TERM
222
+ // trap exits through the EXIT cleanup, which removes the stamp.
223
+ try {
224
+ const stopper = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", 'kill -TERM "$1" 2>/dev/null', "sh", String(pid)]);
225
+ stopper.stdout?.destroy();
226
+ stopper.stderr?.destroy();
227
+ stopper.on("error", () => { });
228
+ stopper.unref?.();
229
+ }
230
+ catch {
231
+ // Best effort: the transport kill already happened.
232
+ }
233
+ }
146
234
  await new Promise((resolveClose) => {
147
235
  if (child.exitCode !== null || child.signalCode !== null)
148
236
  return resolveClose();
@@ -42,12 +42,14 @@ export declare function linuxJoin(linuxRoot: string, path: string): string;
42
42
  /**
43
43
  * The record invocation for one path: line 1 is the no-follow kind (the
44
44
  * upstream lstat gate), line 2 the follow-stat fields (the upstream final
45
- * stat), tab-joined.
45
+ * stat), tab-joined. The path is resolved and contained first — the GUI's
46
+ * workspace is a boundary for every read, not just for listings.
46
47
  *
47
48
  * @param file - absolute Linux path.
49
+ * @param root - the workspace root in Linux form (containment boundary).
48
50
  * @returns the agent argv.
49
51
  */
50
- export declare function statRecordArgv(file: string): string[];
52
+ export declare function statRecordArgv(file: string, root: string): string[];
51
53
  /**
52
54
  * Parse one stat record (kind + follow fields, tab-joined).
53
55
  *
@@ -77,9 +79,10 @@ export declare function wireKind(kind: string): string;
77
79
  * @param file - absolute Linux path.
78
80
  * @param offset - first line to return (1-based).
79
81
  * @param limit - maximum lines to return.
82
+ * @param root - the workspace root in Linux form (containment boundary).
80
83
  * @returns the agent argv.
81
84
  */
82
- export declare function pageArgv(file: string, offset: number, limit: number): string[];
85
+ export declare function pageArgv(file: string, offset: number, limit: number, root: string): string[];
83
86
  /**
84
87
  * The byte-window invocation: the stat record rides stderr, the window rides
85
88
  * stdout. `$1` is the file, `$2` the byte offset, `$3` the byte count.
@@ -87,9 +90,10 @@ export declare function pageArgv(file: string, offset: number, limit: number): s
87
90
  * @param file - absolute Linux path.
88
91
  * @param offset - byte offset (0-based).
89
92
  * @param length - byte count.
93
+ * @param root - the workspace root in Linux form (containment boundary).
90
94
  * @returns the agent argv.
91
95
  */
92
- export declare function bytesArgv(file: string, offset: number, length: number): string[];
96
+ export declare function bytesArgv(file: string, offset: number, length: number, root: string): string[];
93
97
  /**
94
98
  * Parse the stat record a read or byte-window relayed on stderr.
95
99
  *
@@ -64,21 +64,43 @@ export function linuxJoin(linuxRoot, path) {
64
64
  const joined = text.length === 0 ? base : `${base}/${text}`.replace(/\/\.(?=\/|$)/gu, "");
65
65
  return joined.length === 0 ? "/" : joined;
66
66
  }
67
+ /**
68
+ * The containment guard every distro-side workspace read shares. `$1` is the
69
+ * requested file, `$2` the workspace root; both are canonicalized with
70
+ * `realpath -L`, and a target that is neither the root itself nor below it is
71
+ * refused with exit 3 (the `workspace-file/outside-workspace` refusal). The
72
+ * resolved file is left in `real` for the rest of the script.
73
+ *
74
+ * A root of `/` admits every absolute path: the workspace IS the distro. The
75
+ * `-L` spelling matches the listing guard's, so symlinked workspace roots and
76
+ * `..` segments resolve the same way on both sides.
77
+ */
78
+ const CONTAIN_FN = [
79
+ 'contain() {',
80
+ ' real=$(realpath -L -- "$1") || exit 2',
81
+ ' root=$(realpath -L -- "$2") || exit 2',
82
+ ' case "$root" in /) ;; *) case "$real" in "$root"|"$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac;; esac',
83
+ '}',
84
+ ].join("\n");
67
85
  /**
68
86
  * The record invocation for one path: line 1 is the no-follow kind (the
69
87
  * upstream lstat gate), line 2 the follow-stat fields (the upstream final
70
- * stat), tab-joined.
88
+ * stat), tab-joined. The path is resolved and contained first — the GUI's
89
+ * workspace is a boundary for every read, not just for listings.
71
90
  *
72
91
  * @param file - absolute Linux path.
92
+ * @param root - the workspace root in Linux form (containment boundary).
73
93
  * @returns the agent argv.
74
94
  */
75
- export function statRecordArgv(file) {
95
+ export function statRecordArgv(file, root) {
76
96
  const script = [
77
- 'k=$(stat -c %F -- "$1") || exit 2',
78
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
97
+ CONTAIN_FN,
98
+ 'contain "$1" "$2"',
99
+ 'k=$(stat -c %F -- "$real") || exit 2',
100
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
79
101
  "printf '%s\\t%s\\n' \"$k\" \"$s\"",
80
102
  ].join("\n");
81
- return ["sh", "-c", script, "sh", file];
103
+ return ["sh", "-c", script, "sh", file, root];
82
104
  }
83
105
  /**
84
106
  * Parse one stat record (kind + follow fields, tab-joined).
@@ -115,16 +137,19 @@ export function wireKind(kind) {
115
137
  * @param file - absolute Linux path.
116
138
  * @param offset - first line to return (1-based).
117
139
  * @param limit - maximum lines to return.
140
+ * @param root - the workspace root in Linux form (containment boundary).
118
141
  * @returns the agent argv.
119
142
  */
120
- export function pageArgv(file, offset, limit) {
143
+ export function pageArgv(file, offset, limit, root) {
121
144
  const script = [
122
- 'k=$(stat -c %F -- "$1") || exit 2',
123
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
145
+ CONTAIN_FN,
146
+ 'contain "$1" "$4"',
147
+ 'k=$(stat -c %F -- "$real") || exit 2',
148
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
124
149
  "printf '%s\\t%s\\n' \"$k\" \"$s\" >&2",
125
- 'sed -n "${2},$(( $2 + $3 ))p" -- "$1"',
150
+ 'sed -n "${2},$(( $2 + $3 ))p" -- "$real"',
126
151
  ].join("\n");
127
- return ["sh", "-c", script, "sh", file, String(offset), String(limit)];
152
+ return ["sh", "-c", script, "sh", file, String(offset), String(limit), root];
128
153
  }
129
154
  /**
130
155
  * The byte-window invocation: the stat record rides stderr, the window rides
@@ -133,15 +158,18 @@ export function pageArgv(file, offset, limit) {
133
158
  * @param file - absolute Linux path.
134
159
  * @param offset - byte offset (0-based).
135
160
  * @param length - byte count.
161
+ * @param root - the workspace root in Linux form (containment boundary).
136
162
  * @returns the agent argv.
137
163
  */
138
- export function bytesArgv(file, offset, length) {
164
+ export function bytesArgv(file, offset, length, root) {
139
165
  const script = [
140
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
166
+ CONTAIN_FN,
167
+ 'contain "$1" "$4"',
168
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
141
169
  "printf '%s\\n' \"$s\" >&2",
142
- 'tail -c "+$(( $2 + 1 ))" -- "$1" | head -c "$3"',
170
+ 'tail -c "+$(( $2 + 1 ))" -- "$real" | head -c "$3"',
143
171
  ].join("\n");
144
- return ["sh", "-c", script, "sh", file, String(offset), String(length)];
172
+ return ["sh", "-c", script, "sh", file, String(offset), String(length), root];
145
173
  }
146
174
  /**
147
175
  * Parse the stat record a read or byte-window relayed on stderr.
@@ -162,11 +162,13 @@ export async function distroStat({ runner, distro, linuxRoot, path, distroWorksp
162
162
  const linuxFile = linuxJoin(linuxRoot, path);
163
163
  const result = await runner.exec({
164
164
  cwd: "/",
165
- argv: statRecordArgv(linuxFile),
165
+ argv: statRecordArgv(linuxFile, linuxRoot),
166
166
  maxOutputBytes: 4096,
167
167
  timeoutMs: 30_000,
168
168
  signal,
169
169
  });
170
+ if (result.exitCode === 3)
171
+ throw outsideWorkspace(path);
170
172
  if (result.exitCode !== 0)
171
173
  throw notFound(path);
172
174
  const record = parseStatRecord(result.stdout);
@@ -186,11 +188,13 @@ export async function distroRead({ runner, distro, linuxRoot, path, offset, limi
186
188
  const linuxFile = linuxJoin(linuxRoot, path);
187
189
  const result = await runner.exec({
188
190
  cwd: "/",
189
- argv: pageArgv(linuxFile, offset, limit),
191
+ argv: pageArgv(linuxFile, offset, limit, linuxRoot),
190
192
  maxOutputBytes: (maxBytes + 1) * 4,
191
193
  timeoutMs: 60_000,
192
194
  signal,
193
195
  });
196
+ if (result.exitCode === 3)
197
+ throw outsideWorkspace(path);
194
198
  if (result.exitCode === 2)
195
199
  throw notFound(path);
196
200
  if (result.exitCode !== 0 && result.exitCode !== 1) {
@@ -213,16 +217,27 @@ export async function distroRead({ runner, distro, linuxRoot, path, offset, limi
213
217
  catch {
214
218
  throw notText(path);
215
219
  }
216
- const lines = text.length === 0 ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
217
- const bytes = Buffer.byteLength(text, "utf8") + lines;
220
+ // `pageArgv` reads one line past the page so EOF is decidable from the
221
+ // yield; that line is a probe, not part of the answer. Split it off before
222
+ // counting, and count bytes on the page the caller actually receives (a
223
+ // CRLF file's line count is not a byte budget).
224
+ const parts = text.length === 0 ? [] : text.split("\n");
225
+ if (text.endsWith("\n"))
226
+ parts.pop();
227
+ const count = parts.length;
228
+ const truncated = count > limit;
229
+ const kept = truncated ? parts.slice(0, limit) : parts;
230
+ const pageText = truncated ? `${kept.join("\n")}\n` : text;
231
+ const lines = kept.length;
232
+ const bytes = Buffer.byteLength(pageText, "utf8");
218
233
  if (bytes > maxBytes) {
219
234
  throw new RemoteError("workspace-file/too-large", `lines ${offset}-${offset + limit - 1} of "${path}" exceed the ${maxBytes} byte cap`, { path, limit: maxBytes });
220
235
  }
221
- if (text.includes(String.fromCharCode(0)))
236
+ if (pageText.includes(String.fromCharCode(0)))
222
237
  throw notText(path);
223
- const eof = lines < limit;
238
+ const eof = !truncated;
224
239
  const stat = { absolutePath: posixToUnc(distro, linuxFile), version: record.version };
225
- return { ...stat, offset, text, lines, eof };
240
+ return { ...stat, offset, text: pageText, lines, eof };
226
241
  }
227
242
  /**
228
243
  * Read one byte window (or the capped whole file) through the runner.
@@ -248,11 +263,13 @@ export async function distroReadBytes({ runner, distro, linuxRoot, path, offset,
248
263
  throw windowTooLarge(path, window.length, maxBytes);
249
264
  const result = await runner.exec({
250
265
  cwd: "/",
251
- argv: bytesArgv(linuxFile, window.offset, window.length),
266
+ argv: bytesArgv(linuxFile, window.offset, window.length, linuxRoot),
252
267
  maxOutputBytes: window.length + 1024,
253
268
  timeoutMs: 60_000,
254
269
  signal,
255
270
  });
271
+ if (result.exitCode === 3)
272
+ throw outsideWorkspace(path);
256
273
  const record = parseStatRecordFromStderr(result.stderr);
257
274
  if (record === undefined)
258
275
  throw notFound(path);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.9.0",
4
- "description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
3
+ "version": "0.10.0",
4
+ "description": "\u8ba9 DeepSeek Harness \u4ee5 WSL \u5b50\u7cfb\u7edf\u4f5c\u4e3a\u4f1a\u8bdd\u7684\u6267\u884c\u73af\u5883\uff1ashell\u3001\u6587\u4ef6\u8bfb\u5199\u4e0e\u547d\u4ee4\u6267\u884c\u5747\u5728\u5b50\u7cfb\u7edf\u5185\u5b8c\u6210\uff0c\u76ee\u5f55\u9009\u62e9\u5668\u4e0e GUI \u7ec8\u7aef\u540c\u6837\u5b9a\u4f4d\u5230\u5b50\u7cfb\u7edf\u8def\u5f84\uff0c\u5e76\u901a\u8fc7\u5b50\u7cfb\u7edf\u5185\u7684 bubblewrap \u5bf9\u547d\u4ee4\u8fdb\u884c\u6c99\u7bb1\u9694\u79bb",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
@@ -93,6 +93,7 @@
93
93
  "lib/**/*.js",
94
94
  "lib/**/*.d.ts",
95
95
  "agent/*.sh",
96
+ "agent/native/linux-*/dsh-wsl-agent",
96
97
  "locale/**/*.json",
97
98
  "cordis.patch.yml",
98
99
  "examples/**/*.yml",
@@ -118,9 +119,9 @@
118
119
  "lint:types": "tsc --noEmit",
119
120
  "build": "node scripts/build.mjs",
120
121
  "lint:build": "node test/build-freshness.mjs",
121
- "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=84 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/shell.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs",
122
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=84 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/shell.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/session-scope.test.mjs test/session-runtime.test.mjs test/native-agent.test.mjs test/peers.test.mjs",
122
123
  "test:syntax": "node test/syntax.mjs",
123
- "test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/fsio-text.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs",
124
+ "test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/fsio-text.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs test/session-scope.test.mjs test/session-runtime.test.mjs test/native-agent.test.mjs test/peers.test.mjs",
124
125
  "lint:style": "node test/style.mjs",
125
126
  "prepublishOnly": "pnpm test",
126
127
  "probe": "bash test/probe/run.sh",
@@ -161,40 +162,45 @@
161
162
  "url": "https://github.com/Big-Dao/dsh-plugin-wsl-env/issues"
162
163
  },
163
164
  "devDependencies": {
164
- "@deepseek-ai/cordis": "4.0.4",
165
+ "@deepseek-ai/cordis": "4.0.5",
165
166
  "@deepseek-ai/cordis-plugin-include": "*",
166
167
  "@deepseek-ai/cordis-plugin-loader": "*",
167
- "@deepseek-ai/dsh-api-workspace-files": "0.2.0-rc.2",
168
- "@deepseek-ai/dsh-bash-local": "0.2.0-rc.2",
169
- "@deepseek-ai/dsh-fs": "0.2.0-rc.2",
170
- "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2",
171
- "@deepseek-ai/dsh-fs-sandbox": "0.2.0-rc.2",
172
- "@deepseek-ai/dsh-host-directory-picker": "0.2.0-rc.2",
173
- "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
174
- "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2",
175
- "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2",
176
- "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.2",
177
- "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
168
+ "@deepseek-ai/dsh-api-workspace-files": "0.2.1-rc.2",
169
+ "@deepseek-ai/dsh-bash-local": "0.2.1-rc.2",
170
+ "@deepseek-ai/dsh-fs": "0.2.1-rc.2",
171
+ "@deepseek-ai/dsh-fs-local": "0.2.1-rc.2",
172
+ "@deepseek-ai/dsh-fs-sandbox": "0.2.1-rc.2",
173
+ "@deepseek-ai/dsh-host-directory-picker": "0.2.1-rc.2",
174
+ "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
175
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.2",
176
+ "@deepseek-ai/dsh-subprocess": "0.2.1-rc.2",
177
+ "@deepseek-ai/dsh-subprocess-local": "0.2.1-rc.2",
178
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2",
178
179
  "@types/js-yaml": "^4.0.9",
179
180
  "@types/node": "~22.20.5",
180
181
  "js-yaml": "4.3.2",
181
182
  "typescript": "~6.0.3"
182
183
  },
183
184
  "peerDependencies": {
184
- "@deepseek-ai/cordis": "~4.0.4",
185
- "@deepseek-ai/dsh-bash-local": "0.2.0-rc.2",
186
- "@deepseek-ai/dsh-fs": "0.2.0-rc.2",
187
- "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2",
188
- "@deepseek-ai/dsh-host-directory-picker": "0.2.0-rc.2",
189
- "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
190
- "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2",
191
- "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.2",
192
- "@deepseek-ai/schemastery": "~3.18.4"
185
+ "@deepseek-ai/cordis": "~4.0.5",
186
+ "@deepseek-ai/dsh-api-workspace-files": "0.2.1-rc.2",
187
+ "@deepseek-ai/dsh-bash-local": "0.2.1-rc.2",
188
+ "@deepseek-ai/dsh-fs": "0.2.1-rc.2",
189
+ "@deepseek-ai/dsh-fs-local": "0.2.1-rc.2",
190
+ "@deepseek-ai/dsh-host-directory-picker": "0.2.1-rc.2",
191
+ "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
192
+ "@deepseek-ai/dsh-subprocess": "0.2.1-rc.2",
193
+ "@deepseek-ai/dsh-subprocess-local": "0.2.1-rc.2",
194
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2",
195
+ "@deepseek-ai/schemastery": "~3.18.5"
193
196
  },
194
197
  "peerDependenciesMeta": {
195
198
  "@deepseek-ai/cordis": {
196
199
  "optional": true
197
200
  },
201
+ "@deepseek-ai/dsh-api-workspace-files": {
202
+ "optional": true
203
+ },
198
204
  "@deepseek-ai/dsh-bash-local": {
199
205
  "optional": true
200
206
  },
@@ -216,6 +222,9 @@
216
222
  "@deepseek-ai/dsh-subprocess-local": {
217
223
  "optional": true
218
224
  },
225
+ "@deepseek-ai/dsh-typert-protocol": {
226
+ "optional": true
227
+ },
219
228
  "@deepseek-ai/schemastery": {
220
229
  "optional": true
221
230
  }