dsh-plugin-wsl-env 0.1.10 → 0.3.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.
@@ -0,0 +1,498 @@
1
+ #!/bin/sh
2
+ # wsl-agent — the resident in-distro peer of dsh-plugin-wsl-env.
3
+ #
4
+ # The plugin spawns one long-lived instance per distro:
5
+ #
6
+ # wsl.exe -d <distro> --exec <login shell> <this script>
7
+ #
8
+ # and speaks a line protocol over stdio. Fields are separated by `|`; binary
9
+ # payloads (cwd, argv words, env values, captured output) travel as single-line
10
+ # base64. The protocol deliberately avoids JSON: the agent must run under a
11
+ # bare POSIX sh with no dependencies beyond coreutils' `base64`, because the
12
+ # one thing a distro is guaranteed to have is a shell.
13
+ #
14
+ # Handshake — the first line the agent writes:
15
+ # HELLO|wsl-agent|<protocol version>
16
+ # Requests the agent accepts, one per line on stdin:
17
+ # PING -> PONG
18
+ # SHUTDOWN -> exits 0
19
+ # KILL|<id> -> SIGTERM the in-flight request with that id
20
+ # SETENV|<key>|<b64 value> -> export the variable for later requests
21
+ # EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>|<output cap bytes|0>
22
+ # followed by <n args> lines of base64, one argv word each
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.
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.
46
+ #
47
+ # Confinement is NOT this script's business: the host wraps argv in the bwrap
48
+ # profile (lib/sandbox.js) before the frame is sent, so the agent executes
49
+ # exactly what the host decided, sandbox included.
50
+ #
51
+ # Exit codes: 0 on SHUTDOWN or EOF; 130 on an interrupted read. A crash is the
52
+ # host's signal to rebuild once and then fall back to one-shot `wsl.exe` mode.
53
+
54
+ set -u
55
+
56
+ PROTO_VERSION=3
57
+ TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
58
+ OUT_FILE="$TMPDIR_AGENT/out"
59
+ ERR_FILE="$TMPDIR_AGENT/err"
60
+ trap 'rm -rf "$TMPDIR_AGENT"; exit 0' EXIT
61
+
62
+ # The request currently in flight, for KILL and for the exit trap.
63
+ CURRENT_PID=
64
+ CURRENT_ID=
65
+
66
+ b64dec() {
67
+ # `base64 -d` with GNU and BusyBox spellings; a decode failure yields empty.
68
+ printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | base64 --decode 2>/dev/null || :
69
+ }
70
+
71
+ b64enc_file() {
72
+ # `tr` strips the 76-column wraps GNU and BusyBox base64 both emit; the
73
+ # protocol is one line per payload, so a wrapped encode would tear a large
74
+ # RES line into protocol garbage the host would read as unknown lines.
75
+ base64 "$1" 2>/dev/null | tr -d '\n' || base64 -- "$1" 2>/dev/null | tr -d '\n' || :
76
+ }
77
+
78
+ kill_current() {
79
+ if [ -n "$CURRENT_PID" ]; then
80
+ kill -TERM "$CURRENT_PID" 2>/dev/null
81
+ fi
82
+ }
83
+
84
+ shutdown() {
85
+ kill_current
86
+ exit 0
87
+ }
88
+ trap shutdown TERM INT HUP
89
+
90
+ timeout_watcher() {
91
+ # $1 = seconds, $2 = pid, $3 = grace seconds. TERM first; a process that
92
+ # ignores TERM gets KILLed after the grace, so a stuck command cannot hang
93
+ # the request forever.
94
+ sleep "$1"
95
+ kill -TERM "$2" 2>/dev/null
96
+ sleep "${3:-3}"
97
+ kill -KILL "$2" 2>/dev/null
98
+ }
99
+
100
+ # Capture one request's output and answer with RES. $1 = request id, $2 =
101
+ # timeout seconds ('' or 0 = none), $3 = per-stream output cap in bytes (0 or
102
+ # empty = unlimited), $4 = launch mode: `exec` runs "$@" (argv words) as the
103
+ # request process; `subshell` runs ( "$@" ) in a forked subshell, which is how
104
+ # the FS handlers — shell functions, not executables — run.
105
+ run_capture() {
106
+ local req_id timeout_s maxout mode rc watcher_pid out_cut err_cut
107
+ req_id=$1
108
+ timeout_s=$2
109
+ maxout=$3
110
+ mode=$4
111
+ shift 4
112
+ case $maxout in
113
+ ''|*[!0-9]*) maxout=0 ;;
114
+ esac
115
+ : >"$OUT_FILE"
116
+ : >"$ERR_FILE"
117
+ CURRENT_ID=$req_id
118
+ if [ "$mode" = subshell ]; then
119
+ ( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
120
+ else
121
+ "$@" >"$OUT_FILE" 2>"$ERR_FILE" &
122
+ fi
123
+ CURRENT_PID=$!
124
+ watcher_pid=
125
+ case $timeout_s in
126
+ ''|0) ;;
127
+ *)
128
+ timeout_watcher "$timeout_s" "$CURRENT_PID" 3 &
129
+ watcher_pid=$!
130
+ ;;
131
+ esac
132
+ wait "$CURRENT_PID"
133
+ rc=$?
134
+ CURRENT_PID=
135
+ CURRENT_ID=
136
+ if [ -n "$watcher_pid" ]; then
137
+ kill "$watcher_pid" 2>/dev/null
138
+ wait "$watcher_pid" 2>/dev/null
139
+ fi
140
+ # The cap is what keeps a chatty command from becoming a multi-gigabyte
141
+ # base64 blob inside THIS shell's command substitution: only the first
142
+ # $maxout bytes of each stream are encoded, and the flags tell the host
143
+ # which streams were cut. FS frames pass 0 — their payloads are the host's
144
+ # own read windows, already bounded by the protocol.
145
+ out_cut=0
146
+ err_cut=0
147
+ if [ "$maxout" -gt 0 ]; then
148
+ [ "$(wc -c <"$OUT_FILE" 2>/dev/null || printf 0)" -gt "$maxout" ] && out_cut=1
149
+ [ "$(wc -c <"$ERR_FILE" 2>/dev/null || printf 0)" -gt "$maxout" ] && err_cut=1
150
+ printf 'RES|%s|%s|%s|%s|%s|%s\n' "$req_id" "$rc" \
151
+ "$(head -c "$maxout" -- "$OUT_FILE" | base64 | tr -d '\n')" \
152
+ "$(head -c "$maxout" -- "$ERR_FILE" | base64 | tr -d '\n')" \
153
+ "$out_cut" "$err_cut"
154
+ else
155
+ printf 'RES|%s|%s|%s|%s|%s|%s\n' "$req_id" "$rc" \
156
+ "$(b64enc_file "$OUT_FILE")" "$(b64enc_file "$ERR_FILE")" 0 0
157
+ fi
158
+ }
159
+
160
+ # --- filesystem substrate (FS frames) -------------------------------------
161
+ #
162
+ # The model's file tools run HERE, against ext4, instead of through the 9p
163
+ # share. Every handler reports failure as `dsh-fs|<reason>|<b64 message>` on
164
+ # stderr with a nonzero exit; reasons classify the coreutils error text, which
165
+ # is why the handlers pin LC_ALL=C.
166
+
167
+ fs_fail() {
168
+ # $1 = reason token, $2 = message. Exits 1 (the caller's RES carries it).
169
+ # Stderr is the op's error channel; stdout stays pure payload.
170
+ printf 'dsh-fs|%s|%s\n' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')" >&2
171
+ exit 1
172
+ }
173
+
174
+ fs_classify() {
175
+ # $1 = captured command stderr -> one reason token on stdout. Message
176
+ # matching across coreutils vintages: newer gettext says "Already exists"
177
+ # where older said "File exists".
178
+ case $1 in
179
+ *"No such file"*|*"not found"*) printf 'notfound' ;;
180
+ *"Not a directory"*) printf 'notdir' ;;
181
+ *"Permission denied"*) printf 'perm' ;;
182
+ *"Too many levels"*|*"Too many symbolic links"*) printf 'loop' ;;
183
+ *"File exists"*|*"Already exists"*) printf 'exists' ;;
184
+ *) printf 'io' ;;
185
+ esac
186
+ }
187
+
188
+ fs_stat() {
189
+ # $1 = `-L` (follow) or empty (lstat), $2 = base64 path. Emits one
190
+ # TAB-separated record: type mode size dev ino mtime ctime, produced by the
191
+ # SAME `find -printf` directives the list walk uses — `stat`'s own %.9Y
192
+ # rounds timestamps to microseconds where find is ns-exact, and one file
193
+ # then carried TWO different version strings depending on which op answered
194
+ # (a uutils distro). The host normalizes them into the version string.
195
+ local p out ftype
196
+ p=$(b64dec "$2")
197
+ out=$(find $1 "$p" -maxdepth 0 -printf '%y\t%m\t%s\t%D\t%i\t%T@\t%C@' 2>"$ERR_FILE") || {
198
+ fs_fail "$(fs_classify "$(cat "$ERR_FILE")")" "$(cat "$ERR_FILE")"
199
+ }
200
+ if [ "$1" = "-L" ]; then
201
+ # `find -L` reports a link it cannot resolve (dangling) as the link
202
+ # itself; the `stat -L` this op replaces failed on it with ENOENT, and
203
+ # the peer's probe is null for exactly that — so refuse the same way.
204
+ if [ "$(printf '%s' "$out" | cut -f1)" = l ]; then
205
+ fs_fail notfound "cannot stat \"$p\": the link does not resolve"
206
+ fi
207
+ fi
208
+ printf '%s\n' "$out"
209
+ }
210
+
211
+ fs_list() {
212
+ # $1 = base64 directory. One `find` pass, NUL-delimited records of
213
+ # `type size dev ino mtime ctime path`. No `-L`: the type is the entry's own
214
+ # (lstat), so a symlink is VISIBLE as `l` and the host resolves its target's
215
+ # stat and identity with one follow-up per link — the peer lists follow-stats
216
+ # but has to know which entries are links all the same. Names are safe inside
217
+ # records (records split on NUL, names taken after the 6th TAB).
218
+ local d err
219
+ d=$(b64dec "$1")
220
+ find "$d" -mindepth 1 -maxdepth 1 -printf '%y\t%s\t%D\t%i\t%T@\t%C@\t%p\0' 2>"$ERR_FILE" || {
221
+ err=$(cat "$ERR_FILE")
222
+ fs_fail "$(fs_classify "$err")" "$err"
223
+ }
224
+ }
225
+
226
+ fs_realpath() {
227
+ # $1 = base64 path. The distro-side identity: the strict realpath, and on
228
+ # ENOENT the nearest existing ancestor with the missing suffix appended —
229
+ # fsio's `resolveLocalTarget` walk, run where the symlinks actually live.
230
+ local p anc r suffix err
231
+ p=$(b64dec "$1")
232
+ if r=$(realpath -e -- "$p" 2>/dev/null); then
233
+ printf '%s' "$r"
234
+ return 0
235
+ fi
236
+ anc=$(dirname -- "$p")
237
+ while : ; do
238
+ if r=$(realpath -e -- "$anc" 2>/dev/null); then
239
+ suffix=${p#"$anc"}
240
+ case $suffix in
241
+ # Mirrors fsio's substring check, ".." inside the missing suffix is a
242
+ # traversal across a missing directory, refused rather than joined.
243
+ *..*) fs_fail notfound "cannot resolve \"$p\": parent traversal crosses a missing directory" ;;
244
+ esac
245
+ printf '%s' "$r$suffix"
246
+ return 0
247
+ fi
248
+ err=$(dirname -- "$anc")
249
+ [ "$err" = "$anc" ] && break
250
+ anc=$err
251
+ done
252
+ err=$(realpath -e -- "$p" 2>&1)
253
+ fs_fail "$(fs_classify "$err")" "$err"
254
+ }
255
+
256
+ fs_read() {
257
+ # $1 = base64 path, $2 = base64 byte offset, $3 = base64 max bytes. The
258
+ # window is the bound: at most $3 bytes leave the distro per request; the
259
+ # host loops.
260
+ local p offset max rd err
261
+ p=$(b64dec "$1")
262
+ offset=$(b64dec "$2")
263
+ max=$(b64dec "$3")
264
+ [ "$max" -gt 0 ] || return 0
265
+ rd="$TMPDIR_AGENT/read"
266
+ tail -c +"$(( offset + 1 ))" -- "$p" >"$rd" 2>"$ERR_FILE" || {
267
+ err=$(cat "$ERR_FILE")
268
+ fs_fail "$(fs_classify "$err")" "$err"
269
+ }
270
+ head -c "$max" -- "$rd"
271
+ }
272
+
273
+ norm_time() {
274
+ # `seconds.fraction` in the host's 19-digit spelling: the fraction padded to
275
+ # nine digits (truncated beyond), whole seconds padded when the distro's
276
+ # stat has no fraction at all. This is what the host's `nanoseconds()`
277
+ # produces from fs_stat's directives, so the strings compare equal.
278
+ local s=${1%%.*} f=${1#*.}
279
+ if [ "$f" = "$1" ]; then
280
+ f=""
281
+ fi
282
+ while [ ${#f} -lt 9 ]; do
283
+ f="${f}0"
284
+ done
285
+ printf '%s%.9s' "$s" "$f"
286
+ }
287
+
288
+ fs_version() {
289
+ # One path's version string from the SAME producer the stat and list ops
290
+ # use (`find -printf`), joined the way the host joins a stat record's five
291
+ # ingredients. Empty output (non-zero) means the path is gone.
292
+ local out dev ino size mtime ctime
293
+ out=$(find "$1" -maxdepth 0 -printf '%D\t%i\t%s\t%T@\t%C@' 2>/dev/null) || return 1
294
+ dev=$(printf '%s' "$out" | cut -f1)
295
+ ino=$(printf '%s' "$out" | cut -f2)
296
+ size=$(printf '%s' "$out" | cut -f3)
297
+ mtime=$(printf '%s' "$out" | cut -f4)
298
+ ctime=$(printf '%s' "$out" | cut -f5)
299
+ printf '%s:%s:%s:%s:%s' "$dev" "$ino" "$size" "$(norm_time "$mtime")" "$(norm_time "$ctime")"
300
+ }
301
+
302
+ fs_write() {
303
+ # $1 = base64 path, $2 = base64 mode (`-` keeps none), $3 = base64
304
+ # `replace` or `no-replace`, $4 = expected version (`-` for none) — the
305
+ # version string this write was based on, re-verified one syscall before the
306
+ # rename so a concurrent writer wins instead of being clobbered — and $5 =
307
+ # content STILL base64. Mirrors fsio's POSIX publication: a private 0700
308
+ # sibling staging dir, a 0600 temp, fsync, chmod, then an atomic rename — or
309
+ # a hard link that refuses to replace for guarded creates.
310
+ local path mode publish expected dir base staging tmp u err version
311
+ path=$(b64dec "$1")
312
+ mode=$(b64dec "$2")
313
+ publish=$(b64dec "$3")
314
+ expected=$(b64dec "$4")
315
+ dir=$(dirname -- "$path")
316
+ base=$(basename -- "$path")
317
+ [ -n "$base" ] && [ "$base" != "/" ] || fs_fail notfound "empty file name"
318
+ mkdir -p -- "$dir" 2>"$ERR_FILE" || {
319
+ err=$(cat "$ERR_FILE")
320
+ fs_fail "$(fs_classify "$err")" "$err"
321
+ }
322
+ u=$(head -c 8 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n')
323
+ staging="$dir/.$base.$$.${u:-0}.tmpdir"
324
+ mkdir -- "$staging" 2>"$ERR_FILE" || {
325
+ # A read-only bind names itself here — the kernel refusing what the policy
326
+ # granted — so the real stderr travels, not a paraphrase of it.
327
+ err=$(cat "$ERR_FILE")
328
+ fs_fail "$(fs_classify "$err")" "$err"
329
+ }
330
+ chmod 700 -- "$staging" 2>/dev/null
331
+ tmp="$staging/$base.tmp"
332
+ # Decode straight to the file: the base64 text is the one form that crosses
333
+ # a shell variable without a NUL-byte or quoting hazard.
334
+ printf '%s' "$5" | base64 -d >"$tmp" 2>/dev/null || {
335
+ rm -rf -- "$staging"
336
+ fs_fail io "cannot stage content"
337
+ }
338
+ chmod 600 -- "$tmp"
339
+ sync -f -- "$tmp" 2>/dev/null || :
340
+ if [ "$mode" != "-" ]; then
341
+ chmod "$mode" -- "$tmp" 2>"$ERR_FILE" || {
342
+ err=$(cat "$ERR_FILE")
343
+ rm -rf -- "$staging"
344
+ fs_fail io "$err"
345
+ }
346
+ fi
347
+ if [ "$publish" = "replace" ] && [ -n "$expected" ] && [ "$expected" != "-" ]; then
348
+ # The version the overwrite/edit was based on is re-verified HERE — one
349
+ # syscall before the rename, where the host's round-trip-wide window was.
350
+ # The producer is fs_stat's own stat directives, so this comparison never
351
+ # trips the stat/find skew a list-derived version would.
352
+ version=$(fs_version "$path")
353
+ if [ "$version" != "$expected" ]; then
354
+ rm -rf -- "$staging"
355
+ if [ -n "$version" ]; then
356
+ fs_fail stale "cannot write \"$path\": file changed since it was read"
357
+ fi
358
+ fs_fail stale "cannot write \"$path\": file no longer exists"
359
+ fi
360
+ fi
361
+ if [ "$publish" = "no-replace" ]; then
362
+ # Coreutils `ln` resolves an existing DIRECTORY destination (linking beside
363
+ # it) where the peer's link(2) refuses any existing path, so the existence
364
+ # guard is checked before the call and the landing spot verified after it.
365
+ if [ -e "$path" ] || [ -L "$path" ]; then
366
+ rm -rf -- "$staging"
367
+ fs_fail exists "cannot write \"$path\": it already exists"
368
+ fi
369
+ ln -- "$tmp" "$path" 2>"$ERR_FILE" || {
370
+ err=$(cat "$ERR_FILE")
371
+ rm -rf -- "$staging"
372
+ # A concurrent creator wins the race; classify by what is on disk now.
373
+ if [ -e "$path" ] || [ -L "$path" ]; then
374
+ fs_fail exists "$err"
375
+ fi
376
+ fs_fail "$(fs_classify "$err")" "$err"
377
+ }
378
+ if ! [ "$tmp" -ef "$path" ]; then
379
+ # ln linked BESIDE the target (a directory appeared under us): undo the
380
+ # stray link and refuse, the guarded create did not publish.
381
+ rm -f -- "$path/${base}.tmp"
382
+ rm -rf -- "$staging"
383
+ fs_fail exists "cannot write \"$path\": it was created concurrently"
384
+ fi
385
+ else
386
+ mv -f -- "$tmp" "$path" 2>"$ERR_FILE" || {
387
+ err=$(cat "$ERR_FILE")
388
+ rm -rf -- "$staging"
389
+ fs_fail "$(fs_classify "$err")" "$err"
390
+ }
391
+ fi
392
+ rm -rf -- "$staging"
393
+ }
394
+
395
+ fs_dispatch() {
396
+ # $1 = op, $2.. = raw base64 arguments (handlers decode what they need to
397
+ # treat as a path; write's content stays base64 until it hits the file).
398
+ local op
399
+ op=$1
400
+ shift
401
+ LC_ALL=C
402
+ export LC_ALL=C
403
+ case $op in
404
+ stat) fs_stat -L "$1" ;;
405
+ lstat) fs_stat "" "$1" ;;
406
+ list) fs_list "$1" ;;
407
+ realpath) fs_realpath "$1" ;;
408
+ read) fs_read "$1" "$2" "$3" ;;
409
+ write) fs_write "$1" "$2" "$3" "$4" "$5" ;;
410
+ *) fs_fail notfound "unknown fs op" ;;
411
+ esac
412
+ }
413
+
414
+ printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
415
+
416
+ while IFS= read -r line; do
417
+ case $line in
418
+ PING)
419
+ printf 'PONG\n'
420
+ ;;
421
+ SHUTDOWN)
422
+ shutdown
423
+ ;;
424
+ KILL\|*)
425
+ kill_id=${line#KILL|}
426
+ if [ "$kill_id" = "$CURRENT_ID" ]; then
427
+ kill_current
428
+ fi
429
+ ;;
430
+ SETENV\|*)
431
+ rest=${line#SETENV|}
432
+ key=${rest%%|*}
433
+ b64=${rest#*|}
434
+ # Command substitution strips trailing newlines from the decoded value;
435
+ # acceptable for environment values, documented in the protocol.
436
+ decoded=$(b64dec "$b64")
437
+ export "$key=$decoded"
438
+ ;;
439
+ EXEC\|*)
440
+ header=${line#EXEC|}
441
+ req_id=${header%%|*}
442
+ rest=${header#*|}
443
+ cwd_b64=${rest%%|*}
444
+ rest=${rest#*|}
445
+ timeout_s=${rest%%|*}
446
+ rest=${rest#*|}
447
+ nargs=${rest%%|*}
448
+ maxout=${rest#*|}
449
+ cwd=$(b64dec "$cwd_b64")
450
+ if ! cd "$cwd" 2>/dev/null; then
451
+ printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
452
+ continue
453
+ fi
454
+ # Build the argv from the following base64 lines.
455
+ # shellcheck disable=SC2086
456
+ set --
457
+ n=0
458
+ while [ "$n" -lt "$nargs" ]; do
459
+ IFS= read -r arg_b64 || break
460
+ n=$((n + 1))
461
+ set -- "$@" "$(b64dec "$arg_b64")"
462
+ done
463
+ # Dispatched: tell the host now, so its watchdog measures execution and
464
+ # not the queue wait this request just went through.
465
+ printf 'ACK|%s\n' "$req_id"
466
+ run_capture "$req_id" "$timeout_s" "$maxout" exec "$@"
467
+ ;;
468
+ FS\|*)
469
+ header=${line#FS|}
470
+ req_id=${header%%|*}
471
+ rest=${header#*|}
472
+ op=${rest%%|*}
473
+ rest=${rest#*|}
474
+ timeout_s=${rest%%|*}
475
+ nargs=${rest#*|}
476
+ # Arguments stay base64 (write's content must never become a shell
477
+ # variable of raw bytes); handlers decode what they treat as a path.
478
+ set --
479
+ n=0
480
+ while [ "$n" -lt "$nargs" ]; do
481
+ IFS= read -r arg_b64 || break
482
+ n=$((n + 1))
483
+ set -- "$@" "$arg_b64"
484
+ done
485
+ # Same dispatch signal as EXEC: the FS budget starts at the op, not at
486
+ # the queue position. No capture cap — the payloads are the host's own
487
+ # read windows, bounded by the protocol.
488
+ printf 'ACK|%s\n' "$req_id"
489
+ run_capture "$req_id" "$timeout_s" 0 subshell fs_dispatch "$op" "$@"
490
+ ;;
491
+ *)
492
+ printf 'ERR||protocol|%s\n' "$(printf '%s' "unrecognized request" | base64 | tr -d '\n')"
493
+ ;;
494
+ esac
495
+ done
496
+
497
+ # EOF on stdin: the host is gone. The EXIT trap removes the temp dir.
498
+ exit 0
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
@@ -14,13 +14,17 @@ are schema defaults, listed because they are the ones worth knowing.
14
14
  | `wsl-shell` | `distro` | `''` *(shipped)* | distro name; empty means WSL's default distro |
15
15
  | | `shell` | `''` | pin a shell inside the distro; empty means the user's login shell |
16
16
  | | `loginShell` | `true` *(shipped)* | use `<shell> -lc`, which sources your profile, instead of a bare `-c` |
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 |
17
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` |
18
20
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
19
21
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
20
22
  | `wsl-fs` | `distro` | `''` *(shipped)* | as above |
21
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 |
22
24
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
23
- | | `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 |
24
28
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
25
29
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
26
30
  | | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
@@ -50,3 +54,10 @@ confinement. `sandboxMode` then reports `undefined`, and the tool layer tells th
50
54
  model that these operations have no sandbox. That is the documented opt-out; see
51
55
  [ARCHITECTURE.md](ARCHITECTURE.md#sandbox) for what the sandbox does and does not
52
56
  cover.
57
+
58
+ ## `wsl-shell-env`
59
+
60
+ | Key | Default | Meaning |
61
+ | --- | --- | --- |
62
+ | `distro` | `''` | the distro the `DSH_WSL_*` facts describe |
63
+ | `portsRefreshMs` | `10000` | how often the `DSH_WSL_PORTS` listening-port snapshot refreshes through the resident agent |