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 +165 -0
- package/README.md +2 -0
- package/agent/wsl-agent.sh +368 -26
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +4 -1
- package/docs/LIMITATIONS.md +60 -0
- 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 +81 -0
- package/lib/agent-exec.js +22 -12
- package/lib/agent-protocol.js +65 -7
- package/lib/agent.js +211 -61
- package/lib/bwrap.js +74 -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 +239 -6
- package/lib/sandbox.js +32 -27
- package/package.json +5 -2
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)
|
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,7 +53,7 @@
|
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|