dsh-plugin-wsl-env 0.2.0 → 0.4.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.
@@ -18,10 +18,31 @@
18
18
  # SHUTDOWN -> exits 0
19
19
  # KILL|<id> -> SIGTERM the in-flight request with that id
20
20
  # SETENV|<key>|<b64 value> -> export the variable for later requests
21
- # EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>
21
+ # EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>|<output cap bytes|0>
22
22
  # followed by <n args> lines of base64, one argv word each
23
- # -> RES|<id>|<exit code>|<b64 stdout>|<b64 stderr>
23
+ # -> ACK|<id> the moment the request is dequeued for execution — the host
24
+ # arms its stuck-request watchdog on this line, so time spent queued
25
+ # behind an earlier request never counts against a budget
26
+ # -> RES|<id>|<exit code>|<b64 stdout>|<b64 stderr>|<stdout cut?1:0>|<stderr cut?1:0>
27
+ # With a cap, stdout and stderr are each cut at that many bytes and
28
+ # the flags say which streams were cut; 0 means the whole capture.
24
29
  # or ERR|<id>|<reason>|<b64 message> (reason: cwd)
30
+ # FS|<id>|<op>|<timeout seconds|0>|<n args>
31
+ # followed by <n args> lines of base64, one argument each — arguments
32
+ # arrive still base64 so a write's content never passes through a shell
33
+ # variable as bytes
34
+ # ops: stat <path> · lstat <path> · list <dir> · realpath <path>
35
+ # read <path> <offset> <maxbytes> · write <path> <mode|->
36
+ # <replace|no-replace> <expected version|-> <content-b64>
37
+ # -> ACK|<id> at dequeue, then the same RES|<id>|<exit code>|<b64
38
+ # stdout>|<b64 stderr> line as EXEC.
39
+ # A failed op writes `dsh-fs|<reason>|<b64 message>` as the FIRST line
40
+ # of its stderr (reason: notfound notdir perm loop exists stale io),
41
+ # which the host's adapter maps onto the FsError codes the tool layer
42
+ # knows. A `replace` write carrying an expected version re-verifies it
43
+ # against a fresh stat one syscall before the rename and refuses with
44
+ # `stale` on a mismatch — the concurrent writer wins, the model's
45
+ # stale write does not land.
25
46
  #
26
47
  # Confinement is NOT this script's business: the host wraps argv in the bwrap
27
48
  # profile (lib/sandbox.js) before the frame is sent, so the agent executes
@@ -32,15 +53,19 @@
32
53
 
33
54
  set -u
34
55
 
35
- PROTO_VERSION=1
56
+ PROTO_VERSION=3
36
57
  TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
37
58
  OUT_FILE="$TMPDIR_AGENT/out"
38
59
  ERR_FILE="$TMPDIR_AGENT/err"
39
- trap 'rm -rf "$TMPDIR_AGENT"; exit 0' EXIT
60
+ trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; exit 0' EXIT
40
61
 
41
62
  # The request currently in flight, for KILL and for the exit trap.
42
63
  CURRENT_PID=
43
64
  CURRENT_ID=
65
+ # The staged-publication directory of an in-flight FS write. TERM/INT would
66
+ # otherwise orphan it inside the target's own directory; a KILL cannot be
67
+ # trapped, which is what the sweep in fs_write is for.
68
+ CURRENT_STAGING=
44
69
 
45
70
  b64dec() {
46
71
  # `base64 -d` with GNU and BusyBox spellings; a decode failure yields empty.
@@ -76,6 +101,360 @@ timeout_watcher() {
76
101
  kill -KILL "$2" 2>/dev/null
77
102
  }
78
103
 
104
+ # Capture one request's output and answer with RES. $1 = request id, $2 =
105
+ # timeout seconds ('' or 0 = none), $3 = per-stream output cap in bytes (0 or
106
+ # empty = unlimited), $4 = launch mode: `exec` runs "$@" (argv words) as the
107
+ # request process; `subshell` runs ( "$@" ) in a forked subshell, which is how
108
+ # the FS handlers — shell functions, not executables — run.
109
+ run_capture() {
110
+ local req_id timeout_s maxout mode rc watcher_pid out_cut err_cut
111
+ req_id=$1
112
+ timeout_s=$2
113
+ maxout=$3
114
+ mode=$4
115
+ shift 4
116
+ case $maxout in
117
+ ''|*[!0-9]*) maxout=0 ;;
118
+ esac
119
+ : >"$OUT_FILE"
120
+ : >"$ERR_FILE"
121
+ CURRENT_ID=$req_id
122
+ if [ "$mode" = subshell ]; then
123
+ ( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
124
+ else
125
+ "$@" >"$OUT_FILE" 2>"$ERR_FILE" &
126
+ fi
127
+ CURRENT_PID=$!
128
+ watcher_pid=
129
+ case $timeout_s in
130
+ ''|0) ;;
131
+ *)
132
+ timeout_watcher "$timeout_s" "$CURRENT_PID" 3 &
133
+ watcher_pid=$!
134
+ ;;
135
+ esac
136
+ wait "$CURRENT_PID"
137
+ rc=$?
138
+ CURRENT_PID=
139
+ CURRENT_ID=
140
+ if [ -n "$watcher_pid" ]; then
141
+ kill "$watcher_pid" 2>/dev/null
142
+ wait "$watcher_pid" 2>/dev/null
143
+ fi
144
+ # The cap is what keeps a chatty command from becoming a multi-gigabyte
145
+ # base64 blob inside THIS shell's command substitution: only the first
146
+ # $maxout bytes of each stream are encoded, and the flags tell the host
147
+ # which streams were cut. FS frames pass 0 — their payloads are the host's
148
+ # own read windows, already bounded by the protocol.
149
+ out_cut=0
150
+ err_cut=0
151
+ if [ "$maxout" -gt 0 ]; then
152
+ [ "$(wc -c <"$OUT_FILE" 2>/dev/null || printf 0)" -gt "$maxout" ] && out_cut=1
153
+ [ "$(wc -c <"$ERR_FILE" 2>/dev/null || printf 0)" -gt "$maxout" ] && err_cut=1
154
+ printf 'RES|%s|%s|%s|%s|%s|%s\n' "$req_id" "$rc" \
155
+ "$(head -c "$maxout" -- "$OUT_FILE" | base64 | tr -d '\n')" \
156
+ "$(head -c "$maxout" -- "$ERR_FILE" | base64 | tr -d '\n')" \
157
+ "$out_cut" "$err_cut"
158
+ else
159
+ printf 'RES|%s|%s|%s|%s|%s|%s\n' "$req_id" "$rc" \
160
+ "$(b64enc_file "$OUT_FILE")" "$(b64enc_file "$ERR_FILE")" 0 0
161
+ fi
162
+ }
163
+
164
+ # --- filesystem substrate (FS frames) -------------------------------------
165
+ #
166
+ # The model's file tools run HERE, against ext4, instead of through the 9p
167
+ # share. Every handler reports failure as `dsh-fs|<reason>|<b64 message>` on
168
+ # stderr with a nonzero exit; reasons classify the coreutils error text, which
169
+ # is why the handlers pin LC_ALL=C.
170
+
171
+ fs_fail() {
172
+ # $1 = reason token, $2 = message. Exits 1 (the caller's RES carries it).
173
+ # Stderr is the op's error channel; stdout stays pure payload.
174
+ printf 'dsh-fs|%s|%s\n' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')" >&2
175
+ exit 1
176
+ }
177
+
178
+ fs_classify() {
179
+ # $1 = captured command stderr -> one reason token on stdout. Message
180
+ # matching across coreutils vintages: newer gettext says "Already exists"
181
+ # where older said "File exists".
182
+ case $1 in
183
+ *"No such file"*|*"not found"*) printf 'notfound' ;;
184
+ *"Not a directory"*) printf 'notdir' ;;
185
+ *"Permission denied"*) printf 'perm' ;;
186
+ *"Too many levels"*|*"Too many symbolic links"*) printf 'loop' ;;
187
+ *"File exists"*|*"Already exists"*) printf 'exists' ;;
188
+ *) printf 'io' ;;
189
+ esac
190
+ }
191
+
192
+ fs_stat() {
193
+ # $1 = `-L` (follow) or empty (lstat), $2 = base64 path. Emits one
194
+ # TAB-separated record: type mode size dev ino mtime ctime, produced by the
195
+ # SAME `find -printf` directives the list walk uses — `stat`'s own %.9Y
196
+ # rounds timestamps to microseconds where find is ns-exact, and one file
197
+ # then carried TWO different version strings depending on which op answered
198
+ # (a uutils distro). The host normalizes them into the version string.
199
+ local p out ftype
200
+ p=$(b64dec "$2")
201
+ out=$(find $1 "$p" -maxdepth 0 -printf '%y\t%m\t%s\t%D\t%i\t%T@\t%C@' 2>"$ERR_FILE") || {
202
+ fs_fail "$(fs_classify "$(cat "$ERR_FILE")")" "$(cat "$ERR_FILE")"
203
+ }
204
+ if [ "$1" = "-L" ]; then
205
+ # `find -L` reports a link it cannot resolve (dangling) as the link
206
+ # itself; the `stat -L` this op replaces failed on it with ENOENT, and
207
+ # the peer's probe is null for exactly that — so refuse the same way.
208
+ if [ "$(printf '%s' "$out" | cut -f1)" = l ]; then
209
+ fs_fail notfound "cannot stat \"$p\": the link does not resolve"
210
+ fi
211
+ fi
212
+ printf '%s\n' "$out"
213
+ }
214
+
215
+ fs_list() {
216
+ # $1 = base64 directory. One `find` pass, NUL-delimited records of
217
+ # `type size dev ino mtime ctime path`. No `-L`: the type is the entry's own
218
+ # (lstat), so a symlink is VISIBLE as `l` and the host resolves its target's
219
+ # stat and identity with one follow-up per link — the peer lists follow-stats
220
+ # but has to know which entries are links all the same. Names are safe inside
221
+ # records (records split on NUL, names taken after the 6th TAB).
222
+ local d err
223
+ d=$(b64dec "$1")
224
+ find "$d" -mindepth 1 -maxdepth 1 -printf '%y\t%s\t%D\t%i\t%T@\t%C@\t%p\0' 2>"$ERR_FILE" || {
225
+ err=$(cat "$ERR_FILE")
226
+ fs_fail "$(fs_classify "$err")" "$err"
227
+ }
228
+ }
229
+
230
+ fs_realpath() {
231
+ # $1 = base64 path. The distro-side identity: the strict realpath, and on
232
+ # ENOENT the nearest existing ancestor with the missing suffix appended —
233
+ # fsio's `resolveLocalTarget` walk, run where the symlinks actually live.
234
+ local p anc r suffix err
235
+ p=$(b64dec "$1")
236
+ if r=$(realpath -e -- "$p" 2>/dev/null); then
237
+ printf '%s' "$r"
238
+ return 0
239
+ fi
240
+ anc=$(dirname -- "$p")
241
+ while : ; do
242
+ if r=$(realpath -e -- "$anc" 2>/dev/null); then
243
+ suffix=${p#"$anc"}
244
+ case $suffix in
245
+ # Mirrors fsio's substring check, ".." inside the missing suffix is a
246
+ # traversal across a missing directory, refused rather than joined.
247
+ *..*) fs_fail notfound "cannot resolve \"$p\": parent traversal crosses a missing directory" ;;
248
+ esac
249
+ printf '%s' "$r$suffix"
250
+ return 0
251
+ fi
252
+ err=$(dirname -- "$anc")
253
+ [ "$err" = "$anc" ] && break
254
+ anc=$err
255
+ done
256
+ err=$(realpath -e -- "$p" 2>&1)
257
+ fs_fail "$(fs_classify "$err")" "$err"
258
+ }
259
+
260
+ fs_read() {
261
+ # $1 = base64 path, $2 = base64 byte offset, $3 = base64 max bytes. The
262
+ # window is the bound: at most $3 bytes leave the distro per request; the
263
+ # host loops. The remainder is STREAMED through the pipe, never staged —
264
+ # staging copied the whole tail to a temp file once per window, which made
265
+ # a full read of a large file quadratic in the file's own size. tail's
266
+ # stderr lands in ERR_FILE (its SIGPIPE death once `head` has had its fill
267
+ # writes nothing there), so a real read failure is still classified.
268
+ local p offset max err
269
+ p=$(b64dec "$1")
270
+ offset=$(b64dec "$2")
271
+ max=$(b64dec "$3")
272
+ [ "$max" -gt 0 ] || return 0
273
+ : >"$ERR_FILE"
274
+ tail -c +"$(( offset + 1 ))" -- "$p" 2>"$ERR_FILE" | head -c "$max" -- 2>/dev/null
275
+ if [ -s "$ERR_FILE" ]; then
276
+ err=$(cat "$ERR_FILE")
277
+ fs_fail "$(fs_classify "$err")" "$err"
278
+ fi
279
+ }
280
+
281
+ norm_time() {
282
+ # `seconds.fraction` in the host's 19-digit spelling: the fraction padded to
283
+ # nine digits (truncated beyond), whole seconds padded when the distro's
284
+ # stat has no fraction at all. This is what the host's `nanoseconds()`
285
+ # produces from fs_stat's directives, so the strings compare equal.
286
+ local s=${1%%.*} f=${1#*.}
287
+ if [ "$f" = "$1" ]; then
288
+ f=""
289
+ fi
290
+ while [ ${#f} -lt 9 ]; do
291
+ f="${f}0"
292
+ done
293
+ printf '%s%.9s' "$s" "$f"
294
+ }
295
+
296
+ fs_version() {
297
+ # One path's version string from the SAME producer the stat and list ops
298
+ # use (`find -printf`), joined the way the host joins a stat record's five
299
+ # ingredients. Empty output (non-zero) means the path is gone.
300
+ local out dev ino size mtime ctime
301
+ out=$(find "$1" -maxdepth 0 -printf '%D\t%i\t%s\t%T@\t%C@' 2>/dev/null) || return 1
302
+ dev=$(printf '%s' "$out" | cut -f1)
303
+ ino=$(printf '%s' "$out" | cut -f2)
304
+ size=$(printf '%s' "$out" | cut -f3)
305
+ mtime=$(printf '%s' "$out" | cut -f4)
306
+ ctime=$(printf '%s' "$out" | cut -f5)
307
+ printf '%s:%s:%s:%s:%s' "$dev" "$ino" "$size" "$(norm_time "$mtime")" "$(norm_time "$ctime")"
308
+ }
309
+
310
+ drop_staging() {
311
+ # Remove the current write's staging dir and forget it, so the EXIT trap
312
+ # never re-removes (or, worse, removes a newer write's staging) after it.
313
+ rm -rf -- "$staging"
314
+ CURRENT_STAGING=
315
+ }
316
+
317
+ sweep_staging() {
318
+ # $1 = directory, $2 = base name. Remove this directory's leftover staging
319
+ # dirs whose creating agent is gone — the PID rides the name, and a process
320
+ # that is not running cannot clean up after its own KILL. A live PID keeps
321
+ # its dir (the safe direction, PID reuse included).
322
+ local d pid
323
+ for d in "$1/.$2."*.*.tmpdir; do
324
+ [ -d "$d" ] || continue
325
+ pid=${d#"$1/.$2."}
326
+ pid=${pid%%.*}
327
+ case $pid in
328
+ ''|*[!0-9]*) continue ;;
329
+ esac
330
+ kill -0 "$pid" 2>/dev/null || rm -rf -- "$d"
331
+ done
332
+ }
333
+
334
+ fs_write() {
335
+ # $1 = base64 path, $2 = base64 mode (`-` keeps none), $3 = base64
336
+ # `replace` or `no-replace`, $4 = expected version (`-` for none) — the
337
+ # version string this write was based on, re-verified one syscall before the
338
+ # rename so a concurrent writer wins instead of being clobbered — and $5 =
339
+ # content STILL base64. Mirrors fsio's POSIX publication: a private 0700
340
+ # sibling staging dir, a 0600 temp, fsync, chmod, then an atomic rename — or
341
+ # a hard link that refuses to replace for guarded creates.
342
+ local path mode publish expected dir base staging tmp u err version
343
+ path=$(b64dec "$1")
344
+ mode=$(b64dec "$2")
345
+ publish=$(b64dec "$3")
346
+ expected=$(b64dec "$4")
347
+ dir=$(dirname -- "$path")
348
+ base=$(basename -- "$path")
349
+ [ -n "$base" ] && [ "$base" != "/" ] || fs_fail notfound "empty file name"
350
+ # Leftover staging dirs from a KILLed write (a KILL cannot be trapped) would
351
+ # sit in the target's directory forever and show up in listings. The creating
352
+ # agent's PID rides the name: one whose process is gone is an orphan, so
353
+ # sweep it before staging this write.
354
+ sweep_staging "$dir" "$base"
355
+ if [ ! -d "$dir" ]; then
356
+ # Deliberately NO `mkdir -p`: a typo'd parent chain must refuse (the peer's
357
+ # Node-fs publication fails ENOENT there), not materialize silently. An
358
+ # existing directory after a failed mkdir is a concurrent creator, not an
359
+ # error.
360
+ mkdir -- "$dir" 2>"$ERR_FILE" || {
361
+ err=$(cat "$ERR_FILE")
362
+ [ -d "$dir" ] || fs_fail "$(fs_classify "$err")" "$err"
363
+ }
364
+ fi
365
+ u=$(head -c 8 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n')
366
+ staging="$dir/.$base.$$.${u:-0}.tmpdir"
367
+ mkdir -- "$staging" 2>"$ERR_FILE" || {
368
+ # A read-only bind names itself here — the kernel refusing what the policy
369
+ # granted — so the real stderr travels, not a paraphrase of it.
370
+ err=$(cat "$ERR_FILE")
371
+ fs_fail "$(fs_classify "$err")" "$err"
372
+ }
373
+ CURRENT_STAGING=$staging
374
+ chmod 700 -- "$staging" 2>/dev/null
375
+ tmp="$staging/$base.tmp"
376
+ # Decode straight to the file: the base64 text is the one form that crosses
377
+ # a shell variable without a NUL-byte or quoting hazard.
378
+ printf '%s' "$5" | base64 -d >"$tmp" 2>/dev/null || {
379
+ drop_staging
380
+ fs_fail io "cannot stage content"
381
+ }
382
+ chmod 600 -- "$tmp"
383
+ sync -f -- "$tmp" 2>/dev/null || :
384
+ if [ "$mode" != "-" ]; then
385
+ chmod "$mode" -- "$tmp" 2>"$ERR_FILE" || {
386
+ err=$(cat "$ERR_FILE")
387
+ drop_staging
388
+ fs_fail io "$err"
389
+ }
390
+ fi
391
+ if [ "$publish" = "replace" ] && [ -n "$expected" ] && [ "$expected" != "-" ]; then
392
+ # The version the overwrite/edit was based on is re-verified HERE — one
393
+ # syscall before the rename, where the host's round-trip-wide window was.
394
+ # The producer is fs_stat's own stat directives, so this comparison never
395
+ # trips the stat/find skew a list-derived version would.
396
+ version=$(fs_version "$path")
397
+ if [ "$version" != "$expected" ]; then
398
+ drop_staging
399
+ if [ -n "$version" ]; then
400
+ fs_fail stale "cannot write \"$path\": file changed since it was read"
401
+ fi
402
+ fs_fail stale "cannot write \"$path\": file no longer exists"
403
+ fi
404
+ fi
405
+ if [ "$publish" = "no-replace" ]; then
406
+ # Coreutils `ln` resolves an existing DIRECTORY destination (linking beside
407
+ # it) where the peer's link(2) refuses any existing path, so the existence
408
+ # guard is checked before the call and the landing spot verified after it.
409
+ if [ -e "$path" ] || [ -L "$path" ]; then
410
+ drop_staging
411
+ fs_fail exists "cannot write \"$path\": it already exists"
412
+ fi
413
+ ln -- "$tmp" "$path" 2>"$ERR_FILE" || {
414
+ err=$(cat "$ERR_FILE")
415
+ drop_staging
416
+ # A concurrent creator wins the race; classify by what is on disk now.
417
+ if [ -e "$path" ] || [ -L "$path" ]; then
418
+ fs_fail exists "$err"
419
+ fi
420
+ fs_fail "$(fs_classify "$err")" "$err"
421
+ }
422
+ if ! [ "$tmp" -ef "$path" ]; then
423
+ # ln linked BESIDE the target (a directory appeared under us): undo the
424
+ # stray link and refuse, the guarded create did not publish.
425
+ rm -f -- "$path/${base}.tmp"
426
+ drop_staging
427
+ fs_fail exists "cannot write \"$path\": it was created concurrently"
428
+ fi
429
+ else
430
+ mv -f -- "$tmp" "$path" 2>"$ERR_FILE" || {
431
+ err=$(cat "$ERR_FILE")
432
+ drop_staging
433
+ fs_fail "$(fs_classify "$err")" "$err"
434
+ }
435
+ fi
436
+ drop_staging
437
+ }
438
+
439
+ fs_dispatch() {
440
+ # $1 = op, $2.. = raw base64 arguments (handlers decode what they need to
441
+ # treat as a path; write's content stays base64 until it hits the file).
442
+ local op
443
+ op=$1
444
+ shift
445
+ LC_ALL=C
446
+ export LC_ALL=C
447
+ case $op in
448
+ stat) fs_stat -L "$1" ;;
449
+ lstat) fs_stat "" "$1" ;;
450
+ list) fs_list "$1" ;;
451
+ realpath) fs_realpath "$1" ;;
452
+ read) fs_read "$1" "$2" "$3" ;;
453
+ write) fs_write "$1" "$2" "$3" "$4" "$5" ;;
454
+ *) fs_fail notfound "unknown fs op" ;;
455
+ esac
456
+ }
457
+
79
458
  printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
80
459
 
81
460
  while IFS= read -r line; do
@@ -108,7 +487,9 @@ while IFS= read -r line; do
108
487
  cwd_b64=${rest%%|*}
109
488
  rest=${rest#*|}
110
489
  timeout_s=${rest%%|*}
111
- nargs=${rest#*|}
490
+ rest=${rest#*|}
491
+ nargs=${rest%%|*}
492
+ maxout=${rest#*|}
112
493
  cwd=$(b64dec "$cwd_b64")
113
494
  if ! cd "$cwd" 2>/dev/null; then
114
495
  printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
@@ -123,28 +504,33 @@ while IFS= read -r line; do
123
504
  n=$((n + 1))
124
505
  set -- "$@" "$(b64dec "$arg_b64")"
125
506
  done
126
- : >"$OUT_FILE"
127
- : >"$ERR_FILE"
128
- CURRENT_ID=$req_id
129
- "$@" >"$OUT_FILE" 2>"$ERR_FILE" &
130
- CURRENT_PID=$!
131
- watcher_pid=
132
- case $timeout_s in
133
- ''|0) ;;
134
- *)
135
- timeout_watcher "$timeout_s" "$CURRENT_PID" 3 &
136
- watcher_pid=$!
137
- ;;
138
- esac
139
- wait "$CURRENT_PID"
140
- rc=$?
141
- CURRENT_PID=
142
- CURRENT_ID=
143
- if [ -n "$watcher_pid" ]; then
144
- kill "$watcher_pid" 2>/dev/null
145
- wait "$watcher_pid" 2>/dev/null
146
- fi
147
- printf 'RES|%s|%s|%s|%s\n' "$req_id" "$rc" "$(b64enc_file "$OUT_FILE")" "$(b64enc_file "$ERR_FILE")"
507
+ # Dispatched: tell the host now, so its watchdog measures execution and
508
+ # not the queue wait this request just went through.
509
+ printf 'ACK|%s\n' "$req_id"
510
+ run_capture "$req_id" "$timeout_s" "$maxout" exec "$@"
511
+ ;;
512
+ FS\|*)
513
+ header=${line#FS|}
514
+ req_id=${header%%|*}
515
+ rest=${header#*|}
516
+ op=${rest%%|*}
517
+ rest=${rest#*|}
518
+ timeout_s=${rest%%|*}
519
+ nargs=${rest#*|}
520
+ # Arguments stay base64 (write's content must never become a shell
521
+ # variable of raw bytes); handlers decode what they treat as a path.
522
+ set --
523
+ n=0
524
+ while [ "$n" -lt "$nargs" ]; do
525
+ IFS= read -r arg_b64 || break
526
+ n=$((n + 1))
527
+ set -- "$@" "$arg_b64"
528
+ done
529
+ # Same dispatch signal as EXEC: the FS budget starts at the op, not at
530
+ # the queue position. No capture cap — the payloads are the host's own
531
+ # read windows, bounded by the protocol.
532
+ printf 'ACK|%s\n' "$req_id"
533
+ run_capture "$req_id" "$timeout_s" 0 subshell fs_dispatch "$op" "$@"
148
534
  ;;
149
535
  *)
150
536
  printf 'ERR||protocol|%s\n' "$(printf '%s' "unrecognized request" | base64 | tr -d '\n')"
package/cordis.patch.yml CHANGED
@@ -168,6 +168,13 @@
168
168
  config:
169
169
  distro: ''
170
170
  restrictToDistro: true
171
+ # agent (the default): reads/writes/identities run inside the
172
+ # distro over the resident agent (lib/fs-substrate.js) —
173
+ # native symlinks and mode bits, the write guard survives to
174
+ # publication, and confined mutations are kernel-enforced.
175
+ # share: the opt-out — the Windows-side host fs stack over
176
+ # the 9p share.
177
+ substrate: agent
171
178
  - id: persona
172
179
  name: '@deepseek-ai/dsh-persona'
173
180
  config:
@@ -19,7 +19,7 @@ smaller integrations.
19
19
  | Service | Class | File | What it does |
20
20
  |---|---|---|---|
21
21
  | `ctx.shell` | `WslShellExecutor` | [`lib/index.js`](../lib/index.js) | Runs each command as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>`, wrapped in a distro-side `bwrap` sandbox. |
22
- | `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Maps Linux paths onto the distro's UNC share, so the host filesystem stack and the packaged ripgrep work on real distro files. |
22
+ | `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Serves the file tools from real distro files, on one of two I/O substrates (see below): the Windows-side host stack over the distro's UNC share (default), or the resident in-distro agent on ext4. |
23
23
  | `ctx.subprocess` | `WslSubprocessRuntime` | [`lib/subprocess.js`](../lib/subprocess.js) | Opens the GUI terminal inside the distro, in the session workspace. |
24
24
 
25
25
  `WslShellExecutor` extends the shipped `LocalBashExecutor`, and `WslFileSystem`
@@ -92,6 +92,32 @@ Three coordinate systems appear in the code:
92
92
  DSH package and performs no I/O. Its containment helpers decide whether a target
93
93
  path sits under a writable root. The filesystem fence uses them.
94
94
 
95
+ ## The filesystem substrate
96
+
97
+ `wsl-fs` serves `ctx.fs` on one of two I/O substrates, chosen by
98
+ `substrate: share | agent` (default `agent`):
99
+
100
+ | | `agent` (default) | `share` (opt-out) |
101
+ |---|---|---|
102
+ | Reads, writes, identities | the resident in-distro agent, on ext4 | the Windows-side host stack (`dsh-fs-local`) over the distro's 9p share |
103
+ | Symlinks | native; every identity is a distro-side `realpath` | the share cannot traverse them, so a missing read is retried once through the distro's canonical path |
104
+ | Mode bits | native; the publication rename is the kernel's | the share drops a host-side chmod, so publication re-applies the mode from inside the distro before an atomic rename |
105
+ | Guarded creates | the guard is forwarded; publication is a no-replace link, closing the check-then-write window for creates. An overwrite or edit carries its version into the write op and the agent re-verifies it one syscall before the rename — a concurrent writer wins, the stale write refuses with `FS_STALE_VERSION` | checked, then published without a guard (the share has no hard links) |
106
+ | Kernel enforcement | stage two: a mutation runs on a confined resident — one long-lived agent per (distro, policy), whose bwrap profile binds exactly what the mode grants — so the kernel refuses what the check would have, and a check bug cannot become a write outside the workspace. Escalated writes (`danger-full-access`) ride the plain resident. A kernel refusal carries `FS_SANDBOX_DENIED`, the same dialect as the command path. | none: the fence is the host-side check |
107
+ | New files | 0600 — the host backend's own POSIX publication semantics, as on a Linux host | the distro's umask default (0644) |
108
+ | Agent outage | `FS_IO_ERROR` naming the substrate; no silent fallback to the share. A distro without usable bwrap refuses confined mutations with the bootstrap command, the same closed failure the command path has | not applicable |
109
+
110
+ Both substrates fence writes with the same host-side policy check
111
+ (`checkedTarget`), and neither changes what `sandboxMode` reports. The wire
112
+ protocol the agent substrate speaks is documented in
113
+ [`agent/wsl-agent.sh`](../agent/wsl-agent.sh); the orchestration layers are
114
+ [`lib/fsio-agent.js`](../lib/fsio-agent.js) (fsio's mechanics over the FS
115
+ frames), [`lib/fs-substrate.js`](../lib/fs-substrate.js) (the provider-shaped
116
+ operations, including the write/edit guard orchestration), and
117
+ [`lib/fsio-text.js`](../lib/fsio-text.js) (the pure text mechanics, replicated
118
+ from upstream pending the export proposed in
119
+ [UPSTREAM-FSIO-EXPORT.md](UPSTREAM-FSIO-EXPORT.md)).
120
+
95
121
  ## Sandbox
96
122
 
97
123
  ### Why the Windows runner cannot be used
@@ -238,6 +264,16 @@ lib/wsl.js wsl.exe interop primitives (no DSH imports)
238
264
  lib/listing.js pure directory listing and breadcrumb helpers (no DSH imports)
239
265
  lib/index.js WslShellExecutor (ctx.shell) and WslFileSystem (ctx.fs)
240
266
  lib/sandbox.js the distro-side bwrap confinement shared by both providers
267
+ lib/bwrap.js the bwrap command line both confinement sites assemble
268
+ (no imports, so a bare checkout can assert it)
269
+ lib/fsio-text.js the peer's pure text mechanics, replicated pending the
270
+ upstream fsio export (see UPSTREAM-FSIO-EXPORT.md)
271
+ lib/fsio-agent.js fsio's mechanics over the agent's FS frames
272
+ lib/fs-substrate.js the agent substrate's provider-shaped operations
273
+ lib/agent.js the resident in-distro agent's host side
274
+ lib/agent-confined.js the confined residents' factory (one per policy)
275
+ lib/agent-protocol.js the wire protocol codecs (pure)
276
+ agent/wsl-agent.sh the resident in-distro agent (POSIX sh, coreutils only)
241
277
  lib/picker.js WslDirectoryPicker (ctx.directoryPicker)
242
278
  lib/subprocess.js WslSubprocessRuntime (ctx.subprocess), the terminal window
243
279
  lib/auto-preset.js per-session environment selection
@@ -16,12 +16,15 @@ are schema defaults, listed because they are the ones worth knowing.
16
16
  | | `loginShell` | `true` *(shipped)* | use `<shell> -lc`, which sources your profile, instead of a bare `-c` |
17
17
  | | `agent` | `true` | run commands over the resident in-distro agent (one long-lived process; in-distro timeout); any agent failure falls back to the one-shot `wsl.exe` path |
18
18
  | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
19
+ | | `maskWindowsDrive` | `false` | shadow `/mnt` with an empty tmpfs in every confined profile: the Windows drive's files cannot be read or exfiltrated and its executables cannot be launched through interop — the hole that lets a "sandboxed" command reach your full Windows token. A narrowing, not a closure (a command can still write an executable into the workspace and run it — binfmt dispatches on content, not location), so enforcement stays `partial` and the result's sandbox fact reports `windowsDrive: "masked"`. The complete closure is distro-level: `[interop] enabled=false` in `wsl.conf` |
19
20
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
20
21
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
21
22
  | `wsl-fs` | `distro` | `''` *(shipped)* | as above |
22
23
  | | `restrictToDistro` | `true` *(shipped)* | refuse a path in **another distro**'s share. `/mnt/c` is a directory *inside* the pinned distro, so it is not affected. The refusal carries `FS_OUTSIDE_DISTRO`; that is not a sandbox decision, so the file tools do not offer a wider permission for it. Set `false` to allow another distro's share |
23
24
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
24
- | | `resolveSymlinks` | `true` | follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` |
25
+ | | `maskWindowsDrive` | `false` | as on `wsl-shell`: confined fs mutations publish through a resident whose profile shadows `/mnt` (same honest limits) |
26
+ | | `substrate` | `agent` *(shipped)* | which I/O substrate serves the file tools: `agent` — the resident in-distro agent (`lib/fs-substrate.js`), where reads, writes and identities run on ext4: native symlinks, native mode bits, and the mutation guard survives to the write's atomic publication, kernel-enforced by the confined resident when the policy is confined. `share` — the opt-out: the Windows-side host stack over the 9p share, with the symlink-retry and publication hooks. Both substrates fence writes with the same host-side policy check. See [LIMITATIONS.md](LIMITATIONS.md) for what changes between them |
27
+ | | `resolveSymlinks` | `true` | *(share substrate only)* follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` — the agent substrate follows them natively |
25
28
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
26
29
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
27
30
  | | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
@@ -3,9 +3,72 @@
3
3
  What the plugin does not do, and why. A limitation that is documented is a design
4
4
  boundary; one that is not is a bug report waiting to happen.
5
5
 
6
+ ## The filesystem substrate (`wsl-fs` `substrate`)
7
+
8
+ `substrate: "agent"` (the default) serves the file tools through the resident
9
+ in-distro agent, on ext4. `substrate: "share"` is the opt-out: the Windows-side
10
+ host stack over the distro's 9p share. What differs:
11
+
12
+ - **A new file publishes with mode 0600 on the agent substrate.** That is the
13
+ host backend's own POSIX publication semantics (a private 0600 staging file,
14
+ renamed into place), which a Linux-host DSH session has always had. The share
15
+ substrate was the outlier: the share drops mode sets, so new files took the
16
+ distro's umask default (0644). Overwrites and edits keep the original bits on
17
+ both substrates. Run `chmod` (or ask the model to) when a new file must be
18
+ executable.
19
+ - **There is no silent fallback to the share.** When the agent substrate's
20
+ resident process is out — the distro stopped, or the agent crashed past its
21
+ one rebuild — file operations fail with `FS_IO_ERROR` naming the substrate,
22
+ rather than quietly degrading to share semantics that cannot follow symlinks
23
+ or keep mode bits. The share substrate dies with the distro anyway; the
24
+ agent only adds its own failure mode, which is the one honest way to fail.
25
+ The failure message names the opt-out: set `substrate: "share"` in the
26
+ provider config to keep working on the Windows-side share while the agent
27
+ is out.
28
+ - **The mutation guard survives to the write.** `createIfAbsent` publishes with
29
+ a no-replace link inside the distro, which closes the check-then-write window
30
+ for creates outright. An overwrite or edit carries the version it was based
31
+ on into the write op, and the agent re-verifies it against a fresh stat one
32
+ syscall before the rename: a concurrent writer wins and the stale write
33
+ refuses with `FS_STALE_VERSION`. That narrows the share's window from a host
34
+ round trip to kernel-adjacent — the residual stat-then-rename pair is the
35
+ same sliver a Linux-host session's own rename has.
36
+ - **Stage two routes mutations through a confined resident.** A write or edit
37
+ under a confined policy runs on its own long-lived agent, spawned inside the
38
+ bwrap profile that binds exactly what the mode grants — so the kernel, not
39
+ just the host-side check, refuses a write outside the workspace, and the
40
+ refusal carries `FS_SANDBOX_DENIED` (the command path's dialect). Costs to
41
+ know: `bubblewrap` becomes required for confined writes (a distro without it
42
+ refuses them with the bootstrap command, rather than downgrading); a policy
43
+ change addresses a different resident, so the first write after switching the
44
+ Permissions selector pays one agent startup; and a write the policy grants to
45
+ the distro's `/tmp` lands in the confined mount's tmpfs, not the real `/tmp`
46
+ — the same hand-off rule as the commands' `/tmp`: pass files through the
47
+ session workspace.
48
+ - **`resolveSymlinks` is not consulted.** The share cannot traverse Linux
49
+ symlinks, so the share substrate retries missing reads through the distro's
50
+ canonical path; the agent substrate never misses them, so the config key has
51
+ no effect there.
52
+ - **`glob` and `grep` are unchanged on both substrates.** They are tool-layer
53
+ searches running the packaged Windows ripgrep against the share — correct
54
+ results, slow, and `.gitignore` interpreted with Windows rules. Bringing them
55
+ in-distro is future work, not part of the substrate switch.
56
+ - **`watch()` is the same poll loop on both substrates** (a `find -newer` scan
57
+ inside the distro), so the coarse-invalidation caveats below apply equally.
58
+ An inotify backend is planned, not shipped.
59
+
60
+ Everything below applies to the share substrate unless it says otherwise.
61
+
6
62
  - **The sandbox does not govern WSL interop.** A confined command can still run a
7
63
  Windows program, which escapes the Linux-side boundary. The plugin reports this as
8
64
  `enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
65
+ The `maskWindowsDrive` key on `wsl-shell` and `wsl-fs` narrows the hole: the
66
+ confined profile shadows `/mnt` with an empty tmpfs, so the drive's files cannot be
67
+ read or exfiltrated and its executables cannot be launched. It is not a closure — a
68
+ command that can write the workspace can write an executable there and run it
69
+ (binfmt interop dispatches on file content, not location) — so enforcement stays
70
+ `partial`, the result's sandbox fact reports `windowsDrive: "masked"`, and the only
71
+ complete closure remains distro-level (`[interop] enabled=false` in `wsl.conf`).
9
72
  - **`bubblewrap` must be installed**, or both providers fail immediately. The
10
73
  usability probe re-runs after a failure, so installing bubblewrap while the app
11
74
  runs is believed on the next command; only a success is cached.
@@ -30,7 +93,10 @@ boundary; one that is not is a bug report waiting to happen.
30
93
  - The check before a write is "check, then write", which is not atomic. This backend
31
94
  checks the caller's guard itself, because the Windows backend publishes a guarded
32
95
  new file with a hard link, and the share does not support hard links. This is the
33
- race that `dsh-fs-sandbox` already documents.
96
+ race that `dsh-fs-sandbox` already documents — and one reason the share substrate
97
+ is the documented FALLBACK rather than the main path: its publication rides the
98
+ unconfined agent, so it never receives the confined resident's kernel-enforced
99
+ containment. The agent substrate does.
34
100
  - `watch()` observes from inside the distro (a `find -newer` poll loop, see
35
101
  `lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
36
102
  and they arrive on the poll cadence, not instantly. A file created with an mtime