dsh-plugin-wsl-env 0.1.10 → 0.2.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 +103 -0
- package/README.md +19 -2
- package/agent/wsl-agent.sh +156 -0
- package/docs/CONFIGURATION.md +8 -0
- package/docs/LIMITATIONS.md +10 -5
- package/docs/PARITY.md +70 -0
- package/docs/README.zh.md +29 -20
- package/lib/agent-errors.js +33 -0
- package/lib/agent-exec.js +194 -0
- package/lib/agent-protocol.js +116 -0
- package/lib/agent-shared.js +32 -0
- package/lib/agent.js +414 -0
- package/lib/fs-publish.js +42 -0
- package/lib/index.js +192 -7
- package/lib/ports.js +61 -0
- package/lib/sandbox.js +3 -1
- package/lib/shell-env.js +28 -2
- package/lib/subprocess.js +15 -0
- package/lib/terminal-route.js +31 -0
- package/lib/watcher.js +127 -0
- package/package.json +10 -4
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,109 @@ reference below points at that record's numbering.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`truncated` missing from the agent path's output streams** — the bash tool's
|
|
18
|
+
canonical result copies the field unconditionally (`canonicalBashResult`), so an
|
|
19
|
+
`undefined` failed the wire's lossless-JSON snapshot and EVERY shell tool call
|
|
20
|
+
returned "tool bash returned invalid output: value is not lossless JSON". The
|
|
21
|
+
agent path now always reports `truncated: false` (it never truncates; there are
|
|
22
|
+
no spill files). Regression assertion in `test/agent-exec.test.mjs`.
|
|
23
|
+
- **The agent execution path failed every shell call whose request carried an
|
|
24
|
+
absolute UNC workdir** — which is every call a GUI session makes, so the
|
|
25
|
+
harness's own agent session lost its bash tool until the fix. The one-shot
|
|
26
|
+
path converts the spec's workdir inside `argv()` (`toLinuxPath`); the agent
|
|
27
|
+
path carried the workdir as a raw frame field, so the raw UNC reached the
|
|
28
|
+
in-distro `chdir`, the synthesized relay text fired `throwIfWorkdirMissing`,
|
|
29
|
+
and the call died with "could not enter the working directory". The conversion
|
|
30
|
+
now happens at the call site (regression test in `test/shell.test.mjs`).
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **Phase 5: per-session terminal routing.** `spawnTerminal` now routes on the
|
|
35
|
+
request's directory coordinates (`lib/terminal-route.js`, `subprocess-wsl.hostSessions`,
|
|
36
|
+
default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
|
|
37
|
+
a host `powershell.exe` in its own directory — no more distro terminal at
|
|
38
|
+
`/mnt/<drive>/…` for host sessions. The shell menu stays the single configured profile,
|
|
39
|
+
and every non-`wsl.exe` consumer is untouched. Still limited (LIMITATIONS.md): the
|
|
40
|
+
shell menu is one entry, and directory-less launches land in the distro.
|
|
41
|
+
- **Phase 7: developer-loop recipes.** Both READMEs gain a Recipes section: Git
|
|
42
|
+
credential sharing through the Windows Credential Manager, `/mnt/c` 9p
|
|
43
|
+
performance guidance, and the `WSLENV` passthrough policy (prefix-admitted
|
|
44
|
+
`DSH_*`, `/p` translation, `PATH` deliberately excluded).
|
|
45
|
+
- **Phase 6: listening-port visibility.** `DSH_WSL_PORTS` joins the managed
|
|
46
|
+
`DSH_*` facts: the ports with a listener inside the distro, comma-separated,
|
|
47
|
+
refreshed every `portsRefreshMs` (default 10s) through the resident agent by
|
|
48
|
+
reading `/proc/net/tcp{,6}` directly — no `ss`, nothing to install. The
|
|
49
|
+
registry's resolve is synchronous by contract, so the snapshot is served
|
|
50
|
+
stale-but-recent; a dev server started moments ago appears on the next
|
|
51
|
+
refresh. Scoped as before: only sessions whose workspace is inside a distro
|
|
52
|
+
see it. Probed live: a real HTTP server appears in the next snapshot.
|
|
53
|
+
- **Phase 4: bootstrap.** `npm run bootstrap -- [distro] [--install]` checks the
|
|
54
|
+
distro for bubblewrap (required) plus ripgrep and inotifywait (the optional
|
|
55
|
+
backends later phases prefer), prints the exact copy-paste install command for
|
|
56
|
+
the distro's package family (apt/dnf/pacman/zypper), and with `--install` runs
|
|
57
|
+
it as the distro's root through `wsl.exe -u root` — no sudo prompt inside the
|
|
58
|
+
distro, nothing touches the Windows side. The `SANDBOX_UNAVAILABLE` message now
|
|
59
|
+
names the same command instead of a bare `sudo apt install`. Probed live:
|
|
60
|
+
detect mode on a distro that already has bubblewrap.
|
|
61
|
+
- **Phase 3: shell execution through the agent.** `WslShellExecutor` now runs commands
|
|
62
|
+
over the resident agent when it is up (`wsl-shell.agent`, default on): one long-lived
|
|
63
|
+
in-distro process instead of a `wsl.exe` per command, with the timeout enforced
|
|
64
|
+
INSIDE the distro (TERM, then KILL after the grace) instead of against the Windows
|
|
65
|
+
process tree. The confinement argv, the execution-handle shape, the result
|
|
66
|
+
decoration (runner failures, denials, `enforcement: partial`) and the workdir
|
|
67
|
+
failure are all identical to the one-shot path — an out-of-service agent falls back
|
|
68
|
+
to it per call, permanently after the agent's rebuild budget. Probed end-to-end:
|
|
69
|
+
`npm run probe:exec` (confined completion, verbatim shell parsing, in-distro
|
|
70
|
+
timeout, relay-shaped cwd failure, kill). Documented deviation: the agent path has
|
|
71
|
+
no live output streaming — reads after settlement carry the whole stream, and no
|
|
72
|
+
spill files (the protocol line already holds it in memory).
|
|
73
|
+
- **Phase 2 (second half, first slice): in-distro publication through the agent.**
|
|
74
|
+
`WslFileSystem` now carries an optional resident agent (`wsl-fs.agent`, default on):
|
|
75
|
+
when available, publication — the replaced file's mode copied onto the staged temp,
|
|
76
|
+
then the atomic rename — is ONE `exec` inside the distro (`lib/fs-publish.js`)
|
|
77
|
+
instead of a `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
|
|
78
|
+
legacy two-step path for that call; the flag changes round trips, never what is
|
|
79
|
+
published. Probed: the publication script's mode preservation and creation
|
|
80
|
+
behaviour run in `test/probe/agent.sh` (now 10 checks).
|
|
81
|
+
- **Phase 2 (first half): real `watch()`.** `WslFileSystem.watch` now observes from
|
|
82
|
+
inside the distro — a long-lived `wsl.exe` `find -newer` poll loop (`lib/watcher.js`)
|
|
83
|
+
firing the seam's coarse invalidation callback — instead of refusing with
|
|
84
|
+
`FS_IO_ERROR`. Activates on a READY barrier from the loop (a write racing the first
|
|
85
|
+
tick is never invisible), sees creations and deletions (both bump the parent's
|
|
86
|
+
mtime), and cleans up on close. Documented blind spot: files created with an
|
|
87
|
+
mtime older than the stamp (`cp -p`, `tar -x`). Probed end-to-end:
|
|
88
|
+
`npm run probe:watch`. The native-ext4 migration of the remaining fs operations
|
|
89
|
+
stays with Phase 3 of the plan.
|
|
90
|
+
- **Phase 1 of the Remote-WSL parity plan (`docs/PARITY.md`): the resident in-distro
|
|
91
|
+
agent.** `agent/wsl-agent.sh` is a dependency-free POSIX shell peer spawned once per
|
|
92
|
+
distro; `lib/agent.js` owns the lifecycle (handshake, lazy start, idle shutdown, one
|
|
93
|
+
rebuild, permanent fallback to the shipped one-shot `wsl.exe` path) and
|
|
94
|
+
`lib/agent-protocol.js` the line protocol (base64 payloads, NUL-safe argv). Not wired
|
|
95
|
+
into the seams yet — the executor and filesystem migrations are the next phases.
|
|
96
|
+
Probed end-to-end against a real distro: `test/probe/agent.sh` (8 checks, including
|
|
97
|
+
binary-safe payloads, an in-distro timeout kill, and a 2 MB single-line response).
|
|
98
|
+
Unit tests cover the codecs and the lifecycle state machine with a fake transport.
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
|
|
102
|
+
- `docs/PARITY.md`: the plan to reach VS Code Remote-WSL-grade experience — a resident
|
|
103
|
+
in-distro agent as the core, with the `fs` and `shell` seams migrated onto it and the
|
|
104
|
+
current 9p/one-shot paths kept as the documented fallback — plus the Phase-0 contract
|
|
105
|
+
audit that shaped it (watch is a coarse invalidation callback; search spawns the
|
|
106
|
+
packaged ripgrep through `ctx.subprocess`, so the interception point is the
|
|
107
|
+
`subprocess-wsl` subclass).
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- The package description is rewritten in Chinese to state what the plugin does first —
|
|
112
|
+
it makes a WSL distro the session's execution environment — then covers the execution,
|
|
113
|
+
UI, and sandbox aspects in one clause each.
|
|
114
|
+
- Chinese text now says “WSL 子系统” instead of “WSL 发行版”, the phrasing Chinese readers
|
|
115
|
+
more commonly use for a WSL distro (`docs/README.zh.md` and the two archived Chinese
|
|
116
|
+
documents; 99 occurrences).
|
|
117
|
+
|
|
15
118
|
## [0.1.10] - 2026-10-02
|
|
16
119
|
|
|
17
120
|
Metadata and one documentation addition. No behaviour change.
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Step 3 should print a layer named `# == dsh-plugin-wsl-env`, and `- id: terminal
|
|
|
28
28
|
|
|
29
29
|
Then open a folder under `\\wsl.localhost\<distro>\...` in the GUI. The picker lists every installed distro at its root level, and **New terminal** opens a shell in the distro.
|
|
30
30
|
|
|
31
|
-
You also need **bubblewrap inside the distro**: `
|
|
31
|
+
You also need **bubblewrap inside the distro**: run `npm run bootstrap -- <distro> --install` (drop `--install` for a read-only check), or paste `wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap`. Without it every command fails closed, see [Sandbox](#sandbox).
|
|
32
32
|
|
|
33
33
|
Uninstall: `dsh plugin --profile wsl remove dsh-plugin-wsl-env`. Upgrade: run the same `add` command again.
|
|
34
34
|
|
|
@@ -77,11 +77,28 @@ Commands are confined by `bubblewrap` inside the distro, and file writes are che
|
|
|
77
77
|
|
|
78
78
|
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [docs/LIMITATIONS.md](docs/LIMITATIONS.md) for everything the plugin does not do.
|
|
79
79
|
|
|
80
|
+
## Recipes
|
|
81
|
+
|
|
82
|
+
- **Git credential sharing**: let git inside the distro use the Windows-side Git
|
|
83
|
+
Credential Manager — `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
|
|
84
|
+
(adjust the path to your Windows Git install; WSL2's localhost forwarding is
|
|
85
|
+
platform behaviour, so ports a dev server listens on inside the distro are
|
|
86
|
+
reachable from Windows directly).
|
|
87
|
+
- **Paths and performance**: the model sees and operates on Linux paths
|
|
88
|
+
(`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
|
|
89
|
+
9p — noticeably slow for many small files; keep heavy-IO projects on the
|
|
90
|
+
distro filesystem. `npm run bootstrap -- <distro>` also reports whether
|
|
91
|
+
ripgrep and inotifywait (the search and watch backends) are in place.
|
|
92
|
+
- **WSLENV passthrough**: WSL imports only the variables listed in `WSLENV`.
|
|
93
|
+
This plugin admits the managed `DSH_*` namespace by prefix, translating the
|
|
94
|
+
two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
|
|
95
|
+
deliberately never forwarded — it would shadow the distro's own PATH.
|
|
96
|
+
|
|
80
97
|
## Troubleshooting
|
|
81
98
|
|
|
82
99
|
| Symptom | Cause | Fix |
|
|
83
100
|
|---|---|---|
|
|
84
|
-
| every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `
|
|
101
|
+
| every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `npm run bootstrap -- <distro> --install`, or set `sandbox: false` on both providers |
|
|
85
102
|
| a command or write is refused outside the session folder | expected behaviour of `workspace-write` | accept the wider-permission offer, or open a session on the folder you need |
|
|
86
103
|
| writes are refused even inside the workspace | the session is in `read-only` mode | switch the Permissions selector |
|
|
87
104
|
| `dsh plugin add` warns that no layer was activated | the dependency was already installed, so `add` had nothing to record | run `dsh plugin --profile wsl remove dsh-plugin-wsl-env`, then add it again |
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# wsl-agent — the resident in-distro peer of dsh-plugin-wsl-env.
|
|
3
|
+
#
|
|
4
|
+
# The plugin spawns one long-lived instance per distro:
|
|
5
|
+
#
|
|
6
|
+
# wsl.exe -d <distro> --exec <login shell> <this script>
|
|
7
|
+
#
|
|
8
|
+
# and speaks a line protocol over stdio. Fields are separated by `|`; binary
|
|
9
|
+
# payloads (cwd, argv words, env values, captured output) travel as single-line
|
|
10
|
+
# base64. The protocol deliberately avoids JSON: the agent must run under a
|
|
11
|
+
# bare POSIX sh with no dependencies beyond coreutils' `base64`, because the
|
|
12
|
+
# one thing a distro is guaranteed to have is a shell.
|
|
13
|
+
#
|
|
14
|
+
# Handshake — the first line the agent writes:
|
|
15
|
+
# HELLO|wsl-agent|<protocol version>
|
|
16
|
+
# Requests the agent accepts, one per line on stdin:
|
|
17
|
+
# PING -> PONG
|
|
18
|
+
# SHUTDOWN -> exits 0
|
|
19
|
+
# KILL|<id> -> SIGTERM the in-flight request with that id
|
|
20
|
+
# SETENV|<key>|<b64 value> -> export the variable for later requests
|
|
21
|
+
# EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>
|
|
22
|
+
# followed by <n args> lines of base64, one argv word each
|
|
23
|
+
# -> RES|<id>|<exit code>|<b64 stdout>|<b64 stderr>
|
|
24
|
+
# or ERR|<id>|<reason>|<b64 message> (reason: cwd)
|
|
25
|
+
#
|
|
26
|
+
# Confinement is NOT this script's business: the host wraps argv in the bwrap
|
|
27
|
+
# profile (lib/sandbox.js) before the frame is sent, so the agent executes
|
|
28
|
+
# exactly what the host decided, sandbox included.
|
|
29
|
+
#
|
|
30
|
+
# Exit codes: 0 on SHUTDOWN or EOF; 130 on an interrupted read. A crash is the
|
|
31
|
+
# host's signal to rebuild once and then fall back to one-shot `wsl.exe` mode.
|
|
32
|
+
|
|
33
|
+
set -u
|
|
34
|
+
|
|
35
|
+
PROTO_VERSION=1
|
|
36
|
+
TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
|
|
37
|
+
OUT_FILE="$TMPDIR_AGENT/out"
|
|
38
|
+
ERR_FILE="$TMPDIR_AGENT/err"
|
|
39
|
+
trap 'rm -rf "$TMPDIR_AGENT"; exit 0' EXIT
|
|
40
|
+
|
|
41
|
+
# The request currently in flight, for KILL and for the exit trap.
|
|
42
|
+
CURRENT_PID=
|
|
43
|
+
CURRENT_ID=
|
|
44
|
+
|
|
45
|
+
b64dec() {
|
|
46
|
+
# `base64 -d` with GNU and BusyBox spellings; a decode failure yields empty.
|
|
47
|
+
printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | base64 --decode 2>/dev/null || :
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
b64enc_file() {
|
|
51
|
+
# `tr` strips the 76-column wraps GNU and BusyBox base64 both emit; the
|
|
52
|
+
# protocol is one line per payload, so a wrapped encode would tear a large
|
|
53
|
+
# RES line into protocol garbage the host would read as unknown lines.
|
|
54
|
+
base64 "$1" 2>/dev/null | tr -d '\n' || base64 -- "$1" 2>/dev/null | tr -d '\n' || :
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
kill_current() {
|
|
58
|
+
if [ -n "$CURRENT_PID" ]; then
|
|
59
|
+
kill -TERM "$CURRENT_PID" 2>/dev/null
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
shutdown() {
|
|
64
|
+
kill_current
|
|
65
|
+
exit 0
|
|
66
|
+
}
|
|
67
|
+
trap shutdown TERM INT HUP
|
|
68
|
+
|
|
69
|
+
timeout_watcher() {
|
|
70
|
+
# $1 = seconds, $2 = pid, $3 = grace seconds. TERM first; a process that
|
|
71
|
+
# ignores TERM gets KILLed after the grace, so a stuck command cannot hang
|
|
72
|
+
# the request forever.
|
|
73
|
+
sleep "$1"
|
|
74
|
+
kill -TERM "$2" 2>/dev/null
|
|
75
|
+
sleep "${3:-3}"
|
|
76
|
+
kill -KILL "$2" 2>/dev/null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
|
|
80
|
+
|
|
81
|
+
while IFS= read -r line; do
|
|
82
|
+
case $line in
|
|
83
|
+
PING)
|
|
84
|
+
printf 'PONG\n'
|
|
85
|
+
;;
|
|
86
|
+
SHUTDOWN)
|
|
87
|
+
shutdown
|
|
88
|
+
;;
|
|
89
|
+
KILL\|*)
|
|
90
|
+
kill_id=${line#KILL|}
|
|
91
|
+
if [ "$kill_id" = "$CURRENT_ID" ]; then
|
|
92
|
+
kill_current
|
|
93
|
+
fi
|
|
94
|
+
;;
|
|
95
|
+
SETENV\|*)
|
|
96
|
+
rest=${line#SETENV|}
|
|
97
|
+
key=${rest%%|*}
|
|
98
|
+
b64=${rest#*|}
|
|
99
|
+
# Command substitution strips trailing newlines from the decoded value;
|
|
100
|
+
# acceptable for environment values, documented in the protocol.
|
|
101
|
+
decoded=$(b64dec "$b64")
|
|
102
|
+
export "$key=$decoded"
|
|
103
|
+
;;
|
|
104
|
+
EXEC\|*)
|
|
105
|
+
header=${line#EXEC|}
|
|
106
|
+
req_id=${header%%|*}
|
|
107
|
+
rest=${header#*|}
|
|
108
|
+
cwd_b64=${rest%%|*}
|
|
109
|
+
rest=${rest#*|}
|
|
110
|
+
timeout_s=${rest%%|*}
|
|
111
|
+
nargs=${rest#*|}
|
|
112
|
+
cwd=$(b64dec "$cwd_b64")
|
|
113
|
+
if ! cd "$cwd" 2>/dev/null; then
|
|
114
|
+
printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
|
|
115
|
+
continue
|
|
116
|
+
fi
|
|
117
|
+
# Build the argv from the following base64 lines.
|
|
118
|
+
# shellcheck disable=SC2086
|
|
119
|
+
set --
|
|
120
|
+
n=0
|
|
121
|
+
while [ "$n" -lt "$nargs" ]; do
|
|
122
|
+
IFS= read -r arg_b64 || break
|
|
123
|
+
n=$((n + 1))
|
|
124
|
+
set -- "$@" "$(b64dec "$arg_b64")"
|
|
125
|
+
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")"
|
|
148
|
+
;;
|
|
149
|
+
*)
|
|
150
|
+
printf 'ERR||protocol|%s\n' "$(printf '%s' "unrecognized request" | base64 | tr -d '\n')"
|
|
151
|
+
;;
|
|
152
|
+
esac
|
|
153
|
+
done
|
|
154
|
+
|
|
155
|
+
# EOF on stdin: the host is gone. The EXIT trap removes the temp dir.
|
|
156
|
+
exit 0
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -14,6 +14,7 @@ are schema defaults, listed because they are the ones worth knowing.
|
|
|
14
14
|
| `wsl-shell` | `distro` | `''` *(shipped)* | distro name; empty means WSL's default distro |
|
|
15
15
|
| | `shell` | `''` | pin a shell inside the distro; empty means the user's login shell |
|
|
16
16
|
| | `loginShell` | `true` *(shipped)* | use `<shell> -lc`, which sources your profile, instead of a bare `-c` |
|
|
17
|
+
| | `agent` | `true` | run commands over the resident in-distro agent (one long-lived process; in-distro timeout); any agent failure falls back to the one-shot `wsl.exe` path |
|
|
17
18
|
| | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
|
|
18
19
|
| | `cwd` | `''` | default working directory; empty means the distro user's home |
|
|
19
20
|
| | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
|
|
@@ -50,3 +51,10 @@ confinement. `sandboxMode` then reports `undefined`, and the tool layer tells th
|
|
|
50
51
|
model that these operations have no sandbox. That is the documented opt-out; see
|
|
51
52
|
[ARCHITECTURE.md](ARCHITECTURE.md#sandbox) for what the sandbox does and does not
|
|
52
53
|
cover.
|
|
54
|
+
|
|
55
|
+
## `wsl-shell-env`
|
|
56
|
+
|
|
57
|
+
| Key | Default | Meaning |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `distro` | `''` | the distro the `DSH_WSL_*` facts describe |
|
|
60
|
+
| `portsRefreshMs` | `10000` | how often the `DSH_WSL_PORTS` listening-port snapshot refreshes through the resident agent |
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -31,13 +31,18 @@ boundary; one that is not is a bug report waiting to happen.
|
|
|
31
31
|
checks the caller's guard itself, because the Windows backend publishes a guarded
|
|
32
32
|
new file with a hard link, and the share does not support hard links. This is the
|
|
33
33
|
race that `dsh-fs-sandbox` already documents.
|
|
34
|
-
- `watch()`
|
|
35
|
-
|
|
34
|
+
- `watch()` observes from inside the distro (a `find -newer` poll loop, see
|
|
35
|
+
`lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
|
|
36
|
+
and they arrive on the poll cadence, not instantly. A file created with an mtime
|
|
37
|
+
older than the watcher's stamp (`cp -p`, `tar -x`) is not seen until something
|
|
38
|
+
else touches the tree. Watching a 9p share from the Windows side remains
|
|
39
|
+
unsupported — the loop runs where the kernel can actually report change.
|
|
36
40
|
- `glob` and `grep` use the Windows-side ripgrep over the share: correct results, but
|
|
37
41
|
not fast, and `.gitignore` is interpreted with Windows rules. `editText` reads the
|
|
38
42
|
whole file into memory before writing it back.
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
its
|
|
43
|
+
- Terminal routing is per-session by workspace coordinates (`subprocess-wsl.hostSessions`,
|
|
44
|
+
default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
|
|
45
|
+
`powershell.exe` in its own directory. The shell MENU remains the single configured
|
|
46
|
+
profile, and a directory-less launch still lands in the distro.
|
|
42
47
|
- Terminal activity reporting stops at `wsl.exe`, so the controller never reclaims
|
|
43
48
|
these terminals when they are idle.
|
package/docs/PARITY.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Parity with VS Code Remote-WSL
|
|
2
|
+
|
|
3
|
+
What parity means for this plugin, the seam contracts the plan was audited
|
|
4
|
+
against, and the phase checklist. Every phase below ends with rows of this
|
|
5
|
+
table ticked and this document updated in the same change.
|
|
6
|
+
|
|
7
|
+
## What parity means here
|
|
8
|
+
|
|
9
|
+
Remote-WSL's experience rests on one thing: a resident server inside the
|
|
10
|
+
distro. Everything users love — native ext4 semantics, inotify, native
|
|
11
|
+
`rg`, low latency — is a derivative. This plugin's 9p-UNC plus one-shot
|
|
12
|
+
`wsl.exe` model can only approach it. The plan therefore lands a resident
|
|
13
|
+
in-distro agent and migrates the `fs` and `shell` seams onto it, keeping the
|
|
14
|
+
current paths as the documented fallback (`wsl.agent: 'off'` must equal
|
|
15
|
+
today's behaviour byte for byte).
|
|
16
|
+
|
|
17
|
+
Not applicable, recorded so the gap is a decision and not an omission:
|
|
18
|
+
|
|
19
|
+
- IntelliSense, debuggers, the extension marketplace: editor features. The
|
|
20
|
+
DSH analogue is tool latency and correctness (Phases 1–3).
|
|
21
|
+
- Settings Sync, Remote-SSH tunnel chaining, Codespaces: composition-layer
|
|
22
|
+
responsibilities, not this plugin's.
|
|
23
|
+
|
|
24
|
+
## Contract audit (Phase 0)
|
|
25
|
+
|
|
26
|
+
Findings from reading the harness packages inside the app bundle; each one
|
|
27
|
+
shaped a phase of the plan.
|
|
28
|
+
|
|
29
|
+
1. **`ctx.fs.watch` is a coarse invalidation callback, not an event stream.**
|
|
30
|
+
`FileSystem.watch(target, changed, signal)` (dsh-fs) resolves once
|
|
31
|
+
observation is active and returns an async close function; `changed` takes
|
|
32
|
+
no event payload. So the in-distro watcher does not need to classify
|
|
33
|
+
rename vs. modify — any inotify (or polling) hit may call `changed()`.
|
|
34
|
+
Phase 1's watcher is accordingly simpler than planned.
|
|
35
|
+
2. **Search is not an `fs` seam.** `dsh-tool-fs-search` spawns the PACKAGED
|
|
36
|
+
ripgrep binary (`@vscode/ripgrep`) through `ctx.subprocess` with a plain
|
|
37
|
+
argv vector and no shell; raw stdout is parsed by the tool. The correct
|
|
38
|
+
interception point is therefore the plugin's `subprocess-wsl` subclass:
|
|
39
|
+
when `argv[0]` is the packaged rg binary and the spec's cwd is inside this
|
|
40
|
+
distro's share, rewrite the spawn to run a distro-side `rg` (falling back
|
|
41
|
+
to `grep -rE` + `find` when absent) with identical stdout conventions.
|
|
42
|
+
Under the agent, that exec rides the agent's `exec` capability.
|
|
43
|
+
3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
|
|
44
|
+
this plugin replaces: chokidar on 9p is the unreliability that made
|
|
45
|
+
`WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
|
|
46
|
+
4. **`dsh-bash-local` carries the timeout machinery** (`clampTimeout`,
|
|
47
|
+
`deadline`, `timeoutOf`, `BASH_TIMEOUT`), which the WSL executor already
|
|
48
|
+
inherits. Migrating exec onto the agent keeps that shape: the deadline
|
|
49
|
+
arms on the host as today, but the SIGTERM→SIGKILL grace fires inside the
|
|
50
|
+
distro via the agent instead of against the Windows process tree.
|
|
51
|
+
|
|
52
|
+
## Phase checklist
|
|
53
|
+
|
|
54
|
+
| Phase | Deliverable | Parity row |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| 0 | This document; contract audit above | baseline recorded |
|
|
57
|
+
| 1 | `lib/agent/` + `wsl-agent` resident process: NDJSON-over-stdio protocol, capability handshake, lazy start, idle shutdown, crash-rebuild-once, permanent fallback | resident server |
|
|
58
|
+
| 2 | real `watch()` SHIPPED (in-distro poll, `lib/watcher.js`); the native-ext4 migration of the remaining fs operations (and with it the workaround deletions) continues alongside Phase 3 | native file semantics, inotify — watch + publication done |
|
|
59
|
+
| 3 | DONE — `WslShellExecutor` exec via agent; in-distro TERM→KILL; handle shape preserved (deviation: no streaming, no spill files) | native exec latency |
|
|
60
|
+
| 4 | DONE (except the agent/plugin version handshake, which lands with the first agent-behaviour change after release) — `npm run bootstrap`, family-aware install command, `SANDBOX_UNAVAILABLE` names it | open-and-it-works |
|
|
61
|
+
| 5 | DONE (routing half) — per-session routing by workspace coordinates (`terminal-route.js`, `hostSessions`); activity-based idle reclaim of distro terminals remains a documented limitation (PTY activity stops at `wsl.exe`) | terminal per window/session — routing done |
|
|
62
|
+
| 6 | DONE — `DSH_WSL_PORTS` (agent-scanned `/proc/net/tcp{,6}`, timer-refreshed); localhost reachability (WSL2 localhost forwarding is platform behaviour) documented in README | port forwarding visibility |
|
|
63
|
+
| 7 | DONE — recipes section in both READMEs: credential sharing, `/mnt/c` guidance, `WSLENV` policy | developer-loop odds and ends |
|
|
64
|
+
|
|
65
|
+
## Fallback invariant
|
|
66
|
+
|
|
67
|
+
Every phase must keep `wsl.agent: 'off'` (and, per capability, any runtime
|
|
68
|
+
fallback) behaviourally identical to the pre-phase release. Probes assert
|
|
69
|
+
this: `agent.sh` runs the same operation with the agent off and on and
|
|
70
|
+
compares outcomes, not internals.
|
package/docs/README.zh.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/dsh-plugin-wsl-env)
|
|
7
7
|
[](../LICENSE)
|
|
8
8
|
|
|
9
|
-
这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL
|
|
9
|
+
这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL 子系统当作工作环境:命令在子系统里执行,模型的文件工具读写子系统里的真实文件,文件夹选择器可以直接打开子系统目录,GUI 终端也开在子系统里。
|
|
10
10
|
|
|
11
11
|
**只支持 Windows + WSL2。** 安装只需一条命令,插件自身没有依赖。环境按会话生效:打开 Windows 文件夹的会话继续使用原来的 Windows 环境。
|
|
12
12
|
|
|
@@ -26,9 +26,9 @@ dsh --profile wsl # 4. 启动
|
|
|
26
26
|
|
|
27
27
|
第 3 步应该打印出名为 `# == dsh-plugin-wsl-env` 的层,并且 `- id: terminal-controller` 上应出现 `shell: { path: wsl.exe, name: WSL }`。这个包是 DSH bundle,所以第 2 步会把 [`cordis.patch.yml`](../cordis.patch.yml) 作为配置层施加进去,没有任何需要手工合并的补丁。
|
|
28
28
|
|
|
29
|
-
然后在 GUI 里打开 `\\wsl.localhost
|
|
29
|
+
然后在 GUI 里打开 `\\wsl.localhost\<子系统>\...` 下的文件夹。选择器会在根一级列出每个已安装的子系统,**New terminal** 会在子系统里打开 shell。
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
还需要在子系统里安装 **bubblewrap**:运行 `npm run bootstrap -- <子系统> --install`(只读检测去掉 `--install`),或直接执行 `wsl.exe -d <子系统> -u root -- apt-get install -y bubblewrap`。没有它每条命令都会失败关闭,见[沙箱](#沙箱)。
|
|
32
32
|
|
|
33
33
|
卸载:`dsh plugin --profile wsl remove dsh-plugin-wsl-env`。升级:再执行一次同样的 `add` 命令。
|
|
34
34
|
|
|
@@ -36,12 +36,13 @@ dsh --profile wsl # 4. 启动
|
|
|
36
36
|
|
|
37
37
|
## 使用
|
|
38
38
|
|
|
39
|
-
把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"
|
|
39
|
+
把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"。它会在子系统里执行 `uname -r` 并读取该文件,不经过 `/mnt/c`,也不复制文件。
|
|
40
40
|
|
|
41
|
-
-
|
|
42
|
-
- **命令**以 `wsl.exe -d
|
|
43
|
-
-
|
|
44
|
-
- **终端**(右侧栏 → *New terminal
|
|
41
|
+
- **子系统文件夹自动获得子系统环境。** 创建会话时就会绑定 `wsl` preset,所以第一次工具调用就已经正确。
|
|
42
|
+
- **命令**以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 执行,所以你的 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效。shell 不是硬编码的 bash。
|
|
43
|
+
- **文件就是子系统的真实文件。** `/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,`/mnt/c/...` 通向 Windows 磁盘。
|
|
44
|
+
- **终端**(右侧栏 → *New terminal*)在子系统里打开会话目录下的 shell。
|
|
45
|
+
- **端口可见性**:子系统里监听中的端口经 `DSH_WSL_PORTS` 暴露给模型(约每 10 秒刷新);子系统内启动的 dev server 可由模型直接告知确切 URL(WSL2 的 localhost 转发是平台行为,Windows 侧直接可达)。
|
|
45
46
|
- **模型能看到自己的 shell 环境。** 插件向受管的 `DSH_*` 命名空间注册 `DSH_WSL_DISTRO`、`DSH_WSL_SHELL` 和 `DSH_WSL_HOME`。
|
|
46
47
|
- **权限与 Linux 主机一致。** 权限选择器在 `read-only`、`workspace-write`(默认)和 `danger-full-access` 之间切换。被拒绝的命令或写入会带回"放宽权限"的提议;批准后,这一次调用不加沙箱执行。
|
|
47
48
|
|
|
@@ -51,44 +52,52 @@ dsh --profile wsl # 4. 启动
|
|
|
51
52
|
|
|
52
53
|
| 行 | 键 | 默认值 | 说明 |
|
|
53
54
|
|---|---|---|---|
|
|
54
|
-
| `wsl-shell` | `distro` | `''` |
|
|
55
|
+
| `wsl-shell` | `distro` | `''` | 子系统名;空表示用 WSL 的默认子系统 |
|
|
55
56
|
| | `sandbox` | `true` | 用 `bubblewrap` 约束命令;`false` 表示不用沙箱 |
|
|
56
57
|
| `wsl-fs` | `distro` | `''` | 同上 |
|
|
57
|
-
| | `restrictToDistro` | `true` |
|
|
58
|
+
| | `restrictToDistro` | `true` | 拒绝指向**其它子系统**共享的路径;`/mnt/c` 属于本子系统内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
|
|
58
59
|
| | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
|
|
59
60
|
| `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
|
|
60
|
-
| `subprocess-wsl` | `distro` | `''` | GUI
|
|
61
|
+
| `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个子系统 |
|
|
61
62
|
|
|
62
63
|
[`cordis.patch.yml`](../cordis.patch.yml) 是每个随包值的注释参考。[docs/CONFIGURATION.md](CONFIGURATION.md) 列出其余键,包括 `shell`、`loginShell`、`cwd`、`timeoutMs`、`resolveSymlinks`、`preferredDistro` 和 `maxEntries`;[examples/profile.cordis.patch.yml](../examples/profile.cordis.patch.yml) 是一份可照抄的本机层。
|
|
63
64
|
|
|
64
65
|
## 沙箱
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
命令由**子系统内的 `bubblewrap`** 约束,文件写入按同一份策略检查。Windows ACL 沙箱在这里用不了:它的受限令牌完全到不了 WSL。
|
|
67
68
|
|
|
68
|
-
| 模式 |
|
|
69
|
+
| 模式 | 子系统里的命令可以做什么 |
|
|
69
70
|
|---|---|
|
|
70
|
-
| `read-only` |
|
|
71
|
+
| `read-only` | 整个子系统只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
|
|
71
72
|
| `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
|
|
72
73
|
| `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
|
|
73
74
|
|
|
74
75
|
**bubblewrap 是必需项,而且失败是关闭的。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束就在任一 provider 上设 `sandbox: false`,工具层会如实告诉模型这些操作没有沙箱。
|
|
75
76
|
|
|
76
|
-
**上报的强制程度是 `partial` 而不是 `full`。**
|
|
77
|
+
**上报的强制程度是 `partial` 而不是 `full`。** 子系统里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
|
|
77
78
|
|
|
78
79
|
设计见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#sandbox),插件不做的事情见 [docs/LIMITATIONS.md](LIMITATIONS.md)。
|
|
79
80
|
|
|
81
|
+
## 配方
|
|
82
|
+
|
|
83
|
+
- **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
|
|
84
|
+
`git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
|
|
85
|
+
(路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
|
|
86
|
+
- **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep / inotifywait(搜索与监视的后端)是否就位。
|
|
87
|
+
- **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
|
|
88
|
+
|
|
80
89
|
## 常见问题
|
|
81
90
|
|
|
82
91
|
| 现象 | 原因 | 处理 |
|
|
83
92
|
|---|---|---|
|
|
84
|
-
| 每条命令都报 `SANDBOX_UNAVAILABLE` |
|
|
93
|
+
| 每条命令都报 `SANDBOX_UNAVAILABLE` | 子系统里没有 `bubblewrap` | 执行 `npm run bootstrap -- <子系统> --install`,或在两个 provider 上都设 `sandbox: false` |
|
|
85
94
|
| 命令或写入在会话目录之外被拒绝 | `workspace-write` 的预期行为 | 接受工具给出的放宽权限提示,或把会话直接开在需要的目录上 |
|
|
86
95
|
| 连工作区内的写入也被拒绝 | 会处在 `read-only` 模式 | 切换权限选择器 |
|
|
87
96
|
| `dsh plugin add` 提示"没有激活任何层" | 依赖之前已经装过,`add` 没有需要记录的内容 | 先执行 `dsh plugin --profile wsl remove dsh-plugin-wsl-env`,再装一次 |
|
|
88
97
|
| 终端打开后仍然是 `cmd.exe` | 这一层里的 `terminal-controller` 行没有生效 | 用 `dsh --profile wsl --dump-config` 确认能看到 `shell: { path: wsl.exe, name: WSL }` |
|
|
89
98
|
| 改了 `lib/` 但不生效 | ES module 缓存 | 重启应用 |
|
|
90
|
-
| 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows
|
|
91
|
-
| `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash`
|
|
99
|
+
| 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在子系统内开发时用运行时镜像,见[开发](#开发) |
|
|
100
|
+
| `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash` 调用子系统内的工具 |
|
|
92
101
|
| 终端活动显示 `unknown` | 官方 shell 集成只对 POSIX 主机上直接启动的 `bash`/`zsh` 生效 | 关闭标签页以释放进程;空闲回收不会对它触发 |
|
|
93
102
|
| 结果里出现 `FS_*` 码 | 码本身说明了是谁拒绝的、以及怎样解除 | 见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#error-codes) 的错误码表 |
|
|
94
103
|
|
|
@@ -97,8 +106,8 @@ dsh --profile wsl # 4. 启动
|
|
|
97
106
|
```bash
|
|
98
107
|
npm test # 风格与打包检查、语法检查、单元测试
|
|
99
108
|
npm run test:coverage # 带覆盖率阈值的单元测试(需 Node 22.8+)
|
|
100
|
-
npm run probe:sandbox #
|
|
101
|
-
npm run probe #
|
|
109
|
+
npm run probe:sandbox # 在子系统里实测 bubblewrap 能约束什么、不能约束什么
|
|
110
|
+
npm run probe # 文件系统探针,需要真实子系统(仅 Windows + WSL)
|
|
102
111
|
npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
|
|
103
112
|
npm run probe:terminal # 通过终端 provider 打开一个 PTY
|
|
104
113
|
npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error a {@link WslAgent} rejects with when the resident process is out
|
|
3
|
+
* for this host process — the executor's cue to fall back to the one-shot
|
|
4
|
+
* `wsl.exe` path. Deliberately its own class: callers must not confuse
|
|
5
|
+
* "the agent cannot serve" with a failure of the command itself.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-plugin-wsl/agent-errors
|
|
8
|
+
*/
|
|
9
|
+
export class AgentUnavailableError extends Error {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} message - why the agent is out, for the log.
|
|
12
|
+
*/
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "AgentUnavailableError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The request's workdir could not be entered inside the distro. Carries the
|
|
21
|
+
* Linux path so a caller can synthesize the same diagnostic the one-shot
|
|
22
|
+
* `wsl.exe` path produces from the relay's `chdir(...) failed` text.
|
|
23
|
+
*/
|
|
24
|
+
export class CwdError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} linuxPath - the path that could not be entered.
|
|
27
|
+
*/
|
|
28
|
+
constructor(linuxPath) {
|
|
29
|
+
super(`could not enter working directory "${linuxPath}"`);
|
|
30
|
+
this.name = "CwdError";
|
|
31
|
+
this.linuxPath = linuxPath;
|
|
32
|
+
}
|
|
33
|
+
}
|