dsh-plugin-wsl-env 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -12,8 +12,173 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ### Added
16
+
17
+ - **`maskWindowsDrive` on `wsl-shell` and `wsl-fs`** — the mitigation knob for
18
+ the interop hole the sandbox has always documented. The confined profile
19
+ mounts an empty tmpfs over `/mnt`, so the Windows drive's files cannot be
20
+ read or exfiltrated by a "sandboxed" command and its executables cannot be
21
+ launched through interop (the path by which such a command reaches your full
22
+ Windows token). Honest limits, stated wherever the key is: binfmt interop
23
+ dispatches on file content, so a command that can write the workspace can
24
+ still write an executable there and run it — enforcement stays `partial`,
25
+ the result's sandbox fact now carries `windowsDrive: "masked" | "visible"`
26
+ so the model can see which posture it runs under, and the only complete
27
+ closure is distro-level (`[interop] enabled=false` in `wsl.conf`). Default
28
+ `false`: the drive stays visible unless you ask for the mask.
29
+
30
+ ## [0.3.0] - 2026-10-03
31
+
32
+ One theme: the file tools move into the distro. The `substrate: "agent"` I/O
33
+ substrate and its kernel-enforced confined writes land together with the
34
+ enterprise review that hardened them — the watchdog that killed the shared
35
+ agent for a queue it never chose to join, the output captures nothing bounded,
36
+ a root listing that sheared the first character off every child's name, and an
37
+ overwrite guard that stopped at the host.
38
+
39
+ ### Fixed
40
+
41
+ - **One file carries one version string, whichever op answered.** `stat` and
42
+ `list` built their versions from different producers: the stat op used the
43
+ distro's `stat` directives (uutils `stat` rounds timestamps to microseconds)
44
+ while the list walk used `find -printf` (nanosecond-exact), so the same file
45
+ yielded two different version strings and a list-derived guard failed
46
+ against its own stat. `fs_stat` now reads through the same `find -printf`
47
+ the list walk uses — one producer, one string — and `find -L`'s habit of
48
+ reporting a dangling link as the link itself is refused the way the `stat`
49
+ it replaced was, so a dangling link still stats to null like the peer's.
50
+
51
+ - **An overwrite or edit now re-verifies its version distro-side, one syscall
52
+ before the rename.** The module header claimed the mutation guard "survives
53
+ to the write", but the no-replace link guarded only `createIfAbsent`: an
54
+ overwrite or edit checked the version host-side and then published with an
55
+ unconditional `mv -f`, so a concurrent writer (an editor save, `git stash`,
56
+ another session) that landed between the model's stat and the publish was
57
+ silently clobbered with no `FS_STALE_VERSION`. The `write` op now carries
58
+ the version the write was based on (a `replaceIfVersion` guard's version,
59
+ or the stat an edit was read against), and the agent re-verifies it with a
60
+ fresh stat from the SAME producer `fs_stat` uses — refusing with
61
+ `FS_STALE_VERSION` ("file changed since it was read", or "file no longer
62
+ exists") while the concurrent writer's content stays untouched and no
63
+ staging debris is left behind. The window narrows from a host round trip to
64
+ kernel-adjacent — the honest wording, here and in the module headers,
65
+ LIMITATIONS.md and ARCHITECTURE.md, is that only the create case is closed
66
+ outright.
67
+
68
+ - **The agent command path now enforces the output budget it always
69
+ advertised.** `maxOutputBytes` rode the config schema but nothing else: the
70
+ agent captured a command's whole stdout and stderr and base64ed both into
71
+ one RES line — multi-gigabyte output meant multi-gigabyte dash command
72
+ substitutions in the distro and a multi-gigabyte host line, with the result
73
+ claiming `truncated: false` all the way. The EXEC frame now carries the
74
+ caller's per-stream budget (`wsl-shell`'s `maxOutputBytes`, wired from the
75
+ agent execution path); the agent cuts each stream at it — only the first
76
+ bytes are encoded, so distro AND host memory are bounded — and the RES line
77
+ reports which streams were cut, which the handle surfaces as honest
78
+ per-stream `truncated` flags (a guarded-create synthesis and frames without
79
+ a budget still read `false`). The host also defends itself against a broken
80
+ or hostile agent: the unbounded readline accumulator is replaced with a
81
+ bounded line splitter that kills the transport when one stdout line exceeds
82
+ the protocol cap instead of buffering it whole.
83
+
84
+ - **A short-budget request queued behind a long one no longer kills the shared
85
+ agent — and an in-flight command is never silently re-run.** Two defects
86
+ shared one root: the host armed each request's watchdog at SUBMISSION and
87
+ let it kill the one `wsl.exe` carrying every pending request, and the
88
+ rebuild relay then replayed all carried frames verbatim. In the default
89
+ configuration the 10 s `DSH_WSL_PORTS` poller (8 s budget, shared agent) did
90
+ exactly this against any command longer than ~21 s: the poll's watchdog
91
+ killed the transport, the command died mid-flight, and the rebuild
92
+ **re-executed it from scratch** — both callers seeing success (measured: a
93
+ killed `sleep 40` ran twice). Three changes close it: the agent answers
94
+ `ACK|<id>` when it dequeues a request and the host arms the watchdog there,
95
+ so queue time never spends a budget (wire protocol v3 — sync the runtime
96
+ copy and restart the app, an old agent fails the handshake loudly instead of
97
+ misbehaving quietly); the rebuild relays only provably read-only FS frames
98
+ (`stat`/`lstat`/`list`/`realpath`/`read`), failing an in-flight EXEC or
99
+ `write` with "its state is unknown — inspect the distro before retrying"
100
+ instead of re-running it; and an abort now addresses the request's CURRENT
101
+ id, so a relayed request's KILL (and map cleanup) can no longer miss. The
102
+ watchdog-vs-queue experiment now ends with the long command executed exactly
103
+ once and the transport alive.
104
+ - **A root listing kept every child's name whole.** `listChildren` sliced each
105
+ `find` record at `parent.length + 1`, which against the bare `/` parent
106
+ sheared the first character off every name (`/etc` → `tc`), and a write to a
107
+ listed child then created `/tc`. The exact prefix is stripped instead, and
108
+ the child join owes its own slash, so `listDir` on
109
+ `\\wsl.localhost\<distro>\` names `/etc`, `/home`, `/usr` correctly.
110
+
111
+ ### Added
112
+
113
+ - **`substrate: "agent"` — the filesystem's in-distro I/O substrate.** `wsl-fs`
114
+ gains a `substrate` key, default `agent`: the file tools are served through
115
+ the resident in-distro agent instead of the 9p share — identities are
116
+ distro-side `realpath`s, symlinks resolve where they live (no retry
117
+ machinery, `/etc/os-release` just works), mode bits are native, and a
118
+ mutation guard survives to the write's no-replace publication — the
119
+ check-then-write window the share documents closes, exactly as on a Linux
120
+ host. `substrate: "share"` is the opt-out that keeps the previous behaviour
121
+ byte for byte. Both substrates fence writes with the same host-side policy
122
+ check, so this changes fidelity and posture, not enforcement. Known
123
+ differences are in LIMITATIONS.md (notably: new files publish at 0600, the
124
+ peer's own POSIX semantics, where the share took the umask 0644), and an
125
+ agent outage fails with `FS_IO_ERROR` naming the substrate rather than
126
+ degrading to share semantics. Layers: FS operation frames on the agent wire
127
+ protocol (v3, `agent/wsl-agent.sh`), fsio-over-agent mechanics
128
+ (`lib/fsio-agent.js`), provider-shaped orchestration (`lib/fs-substrate.js`),
129
+ and the provider branch (`lib/index.js`). The pure text mechanics are
130
+ replicated from upstream pending the fsio export proposed in
131
+ docs/UPSTREAM-FSIO-EXPORT.md. Probed live: `npm run probe:substrate`.
132
+ - **Stage two: kernel-enforced confined writes.** On the agent substrate, a
133
+ mutation under a confined policy rides its own long-lived resident — one
134
+ agent per (distro, mode, workspace), spawned inside the bwrap profile that
135
+ binds exactly what the mode grants (`lib/agent-confined.js`, `argvPrefix` on
136
+ the agent wire). The kernel now refuses what the host-side check would have,
137
+ so a check bug cannot become a write outside the workspace; a mount-table
138
+ refusal surfaces as `FS_SANDBOX_DENIED`, the command path's dialect, and a
139
+ distro without usable bwrap refuses confined writes with the bootstrap
140
+ command instead of downgrading. Escalated (`danger-full-access`) writes ride
141
+ the plain resident, and a policy change simply addresses a different key —
142
+ the previous resident retires through its idle timer. Probed live: the
143
+ confined write inside the workspace lands, and the one outside it is refused
144
+ by the kernel (`probe:substrate`).
145
+
15
146
  ### Fixed
16
147
 
148
+ - **Every mutation on the agent substrate failed: the confined resident's command
149
+ had no program** — `confinedAgent()` built the resident's `argvPrefix` from the
150
+ raw profile arguments, so the transport handed `wsl.exe` a command beginning at
151
+ `--ro-bind` instead of `bwrap --ro-bind`. The resident exited 1 during its
152
+ handshake, and every write, edit and guarded create on `substrate: "agent"`
153
+ failed with `FS_IO_ERROR: the distro file substrate is unavailable: agent exited
154
+ during handshake (code=1 signal=none)` while reads kept working. Both confinement
155
+ sites now assemble the command through the import-free `lib/bwrap.js`, which is
156
+ what `test/agent.test.mjs` asserts; the same bug stayed invisible because
157
+ `test/probe/substrate.sh` inlined a *third*, correct copy of those arguments —
158
+ it now imports the production builder, so the probe fails with the provider.
159
+ - **`WslFileSystem` could not build its agent substrate at all: a constructor
160
+ field shadowed the method of the same name** — the constructor's
161
+ `this.agentSubstrate = undefined` (the cache) overwrote the prototype's
162
+ `async agentSubstrate()` (the accessor), so the first file operation that needed
163
+ the agent threw `this.agentSubstrate is not a function`, which is how a GUI
164
+ session failed its turn in the Desktop app. The cache is now
165
+ `this.agentSubstrateInstance`, named after the sibling `this.agentInstance`, and
166
+ `lib/index.js`'s instance fields no longer collide with any class member.
167
+ - **A schemastery member the pinned peer does not publish took the `wsl` preset
168
+ down in the Desktop app** — `WslFileSystem.Config` declared
169
+ `substrate: z.enum(["share", "agent"])`, and `@deepseek-ai/schemastery` 3.18.4
170
+ (what every `@deepseek-ai/dsh-*` peer pins as `~3.18.4`) publishes no `enum`. A
171
+ Config schema is a class field, so the call ran while `lib/index.js` was still
172
+ being imported: the loader logs the `TypeError` and leaves that row with no
173
+ fiber, which the preset audit renders as `never started` — naming neither the
174
+ member nor the file, and only for `wsl-shell` and `wsl-fs`, the two rows that
175
+ load `lib/index.js`. The preset therefore failed to activate, so the app
176
+ reported `2 row(s) did not activate` for those two ids. Now
177
+ `z.union(["share", "agent"])`: same `agent` default, and a bad value still
178
+ fails with `expected "share" | "agent" but got "bogus"`. Measured against the
179
+ app's own runtime, both entries import; `test/syntax.mjs` now checks every
180
+ `z.<member>` in `lib/` against the pinned peer's surface, which fails on
181
+ `z.enum` and passes on `z.union`.
17
182
  - **`truncated` missing from the agent path's output streams** — the bash tool's
18
183
  canonical result copies the field unconditionally (`canonicalBashResult`), so an
19
184
  `undefined` failed the wire's lossless-JSON snapshot and EVERY shell tool call
package/README.md CHANGED
@@ -56,6 +56,7 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
56
56
  | `wsl-fs` | `distro` | `''` | as above |
57
57
  | | `restrictToDistro` | `true` | refuse a path in **another distro**'s share; `/mnt/c` is inside this distro and is not affected. Refused with `FS_OUTSIDE_DISTRO`, which is not a sandbox denial and cannot be lifted by wider permissions |
58
58
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
59
+ | | `substrate` | `agent` | which I/O substrate serves the file tools: `agent` — the resident in-distro agent, where reads, writes and identities run on ext4 (native symlinks and mode bits; the write guard survives to publication, kernel-enforced under a confined policy); `share` — the opt-out: the Windows-side host stack over the 9p share. Both fence writes with the same policy; see [LIMITATIONS.md](docs/LIMITATIONS.md) for what changes |
59
60
  | `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory |
60
61
  | `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
61
62
 
@@ -118,6 +119,7 @@ npm run probe:sandbox # measure inside the distro what bubblewrap does an
118
119
  npm run probe # filesystem probe against a real distro (Windows + WSL only)
119
120
  npm run probe:sandbox-shell # boot a real harness and drive the confined executor
120
121
  npm run probe:terminal # open a PTY through the terminal provider
122
+ npm run probe:substrate # drive the agent filesystem substrate over a real wsl.exe transport (from inside the distro)
121
123
  npm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
122
124
  npm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
123
125
  npm run probe:mode # which POSIX-mode facts survive the share (Windows node only)
@@ -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,7 +53,7 @@
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"
@@ -76,6 +97,320 @@ timeout_watcher() {
76
97
  kill -KILL "$2" 2>/dev/null
77
98
  }
78
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
+
79
414
  printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
80
415
 
81
416
  while IFS= read -r line; do
@@ -108,7 +443,9 @@ while IFS= read -r line; do
108
443
  cwd_b64=${rest%%|*}
109
444
  rest=${rest#*|}
110
445
  timeout_s=${rest%%|*}
111
- nargs=${rest#*|}
446
+ rest=${rest#*|}
447
+ nargs=${rest%%|*}
448
+ maxout=${rest#*|}
112
449
  cwd=$(b64dec "$cwd_b64")
113
450
  if ! cd "$cwd" 2>/dev/null; then
114
451
  printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
@@ -123,28 +460,33 @@ while IFS= read -r line; do
123
460
  n=$((n + 1))
124
461
  set -- "$@" "$(b64dec "$arg_b64")"
125
462
  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")"
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" "$@"
148
490
  ;;
149
491
  *)
150
492
  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: