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.
- package/CHANGELOG.md +294 -0
- package/README.md +2 -0
- package/agent/wsl-agent.sh +413 -27
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +4 -1
- package/docs/LIMITATIONS.md +67 -1
- package/docs/PARITY.md +5 -4
- package/docs/PEER-PARITY.md +60 -0
- package/docs/README.zh.md +2 -0
- package/docs/UPSTREAM-FSIO-EXPORT.md +87 -0
- package/lib/agent-confined.js +87 -0
- package/lib/agent-exec.js +22 -12
- package/lib/agent-protocol.js +65 -7
- package/lib/agent.js +279 -64
- package/lib/bwrap.js +91 -0
- package/lib/fs-decisions.js +132 -0
- package/lib/fs-substrate.js +285 -0
- package/lib/fsio-agent.js +481 -0
- package/lib/fsio-text.js +153 -0
- package/lib/index.js +271 -26
- package/lib/paths.js +14 -0
- package/lib/sandbox.js +32 -27
- package/lib/shell-env.js +6 -1
- package/package.json +6 -3
package/agent/wsl-agent.sh
CHANGED
|
@@ -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
|
-
# ->
|
|
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=
|
|
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
|
-
|
|
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
|
-
:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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) |
|
|
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
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
-
| | `
|
|
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 |
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -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
|