dsh-plugin-wsl-env 0.7.2 → 0.7.3

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,6 +12,59 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.7.3] - 2026-10-05
16
+
17
+ One theme: the resident agent stops taking its world on faith - the
18
+ handshake now verifies the deployment's content, the agent's lifetime
19
+ survives a wedged host only by lease, and the one 9P consumer no row
20
+ replacement could reach ships its seam-ready traversal.
21
+
22
+ ### Added
23
+
24
+ - **The resident agent verifies its own deployment, and outlives a wedged
25
+ host only by lease.** The agent script is read in place from the
26
+ distro-visible path - in the deployed layout a `/mnt/c` mirror - so a
27
+ stale or half-synced runtime copy used to pass the version handshake
28
+ while drifting in behavior. The HELLO line now carries the sha256 of the
29
+ file the distro actually read (protocol v4), and both resident factories
30
+ pin the hash of the copy the package shipped: a mismatch is refused like
31
+ a version mismatch, with the resync command in the message. The agent
32
+ also terminates itself when its host goes silent without ever delivering
33
+ EOF - the wedged-relay case stdin EOF cannot cover: the host forwards a
34
+ client lease (`idleMs` + grace) through the managed `DSH_` namespace, and
35
+ an in-distro watchdog polls an in-flight marker so a long command's
36
+ silence never counts against it (dash has no `read -t`; a marker file is
37
+ the portable form). A boot sweep removes sibling `wsl-agent.*` temp dirs
38
+ no request has touched for an hour - the residue of agents whose host
39
+ died before their exit trap could run.
40
+ - **The `@` completion's distro traversal is built and waiting for its seam.**
41
+ The one 9P consumer the takeover series could not reach - the GUI's `@`
42
+ file completion - funnels all workspace traversal through three
43
+ module-internal functions of the upstream `file-reference-local` service,
44
+ so no row replacement can reach them. The plugin now ships the consumer
45
+ side of RFC addendum ① (`lib/file-reference-wsl.js`): a traversal strategy
46
+ that lists a distro workspace through the resident agent with
47
+ `find -printf '%y\t%f'` - keeping readdir's Dirent fidelity, where
48
+ symlinks report neither kind and are skipped instead of resolved through
49
+ like `ls -L` would - replaces the upstream per-segment lstat walk with one
50
+ agent exec, and returns absolutes in the root's own coordinate system,
51
+ which the upstream scan joins children with. Deliberately not wired into
52
+ the composition: with the seam absent, reaching the funnels would mean
53
+ re-implementing the fuzzy ranking engine, the behavior-regression path
54
+ the RFC rules out. The module ships tested; the takeover lands the day
55
+ upstream takes the ~10-line parameter. The RFC addendum now carries the
56
+ concrete interface proposal.
57
+
58
+ ### Fixed
59
+
60
+ - **The Windows CI legs are green again.** The fs boot test's end-to-end
61
+ legs read `platform is win32` as `the real-distro topology exists`, but
62
+ the CI Windows runners ship wsl.exe with no distro installed - every
63
+ Windows leg since the boot test landed failed there, which kept the
64
+ release gates red through 0.7.0-0.7.2 even after the missing-module
65
+ fix. The guard now asks WSL for its distro list and skips unless the
66
+ test's distro is actually installed.
67
+
15
68
  ## [0.7.2] - 2026-10-05
16
69
 
17
70
  One theme: the root-plane routing lands - and the composition that
@@ -12,7 +12,11 @@
12
12
  # one thing a distro is guaranteed to have is a shell.
13
13
  #
14
14
  # Handshake — the first line the agent writes:
15
- # HELLO|wsl-agent|<protocol version>
15
+ # HELLO|wsl-agent|<protocol version>|<sha256 of this script file>
16
+ # The digest lets the host verify that the script it is about to rely on is
17
+ # byte-identical to the copy it shipped. The script is read in place — a
18
+ # /mnt/c mirror in the deployed layout — so a stale or half-synced runtime
19
+ # copy would otherwise pass the version handshake while drifting in behavior.
16
20
  # Requests the agent accepts, one per line on stdin:
17
21
  # PING -> PONG
18
22
  # SHUTDOWN -> exits 0
@@ -57,11 +61,55 @@
57
61
 
58
62
  set -u
59
63
 
60
- PROTO_VERSION=3
64
+ PROTO_VERSION=4
61
65
  TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
62
66
  OUT_FILE="$TMPDIR_AGENT/out"
63
67
  ERR_FILE="$TMPDIR_AGENT/err"
64
- trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; exit 0' EXIT
68
+ trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; [ -z "${WATCHDOG_PID:-}" ] || kill "$WATCHDOG_PID" 2>/dev/null; exit 0' EXIT
69
+
70
+ # The content identity of this script file, reported in the handshake: the
71
+ # host hashes its own shipped copy and refuses an agent whose file differs.
72
+ # cut with an explicit space: sha256sum separates hash and file with spaces,
73
+ # and the default tab delimiter would leave the whole line in the field.
74
+ AGENT_DIGEST=$(sha256sum -- "$0" 2>/dev/null | cut -d' ' -f1)
75
+
76
+ # Bounded cleanup: temp dirs from agents whose host died without giving them
77
+ # an EXIT (a `wsl --shutdown`, a force-killed wsl.exe). Own pattern only, and
78
+ # only when nothing inside moved for an hour — a live agent's dir mtime moves
79
+ # on every request (the inflight marker in run_capture is created and
80
+ # removed), so a sibling being served right now is never touched. A confined
81
+ # agent's /tmp is a private tmpfs: its own dir vanishes with it and it sees
82
+ # no siblings, so the sweep is a no-op there.
83
+ for stale in "${TMPDIR:-/tmp}"/wsl-agent.*; do
84
+ [ -d "$stale" ] || continue
85
+ [ "$stale" = "$TMPDIR_AGENT" ] && continue
86
+ [ -z "$(find "$stale" -maxdepth 1 -mmin -60 -print -quit 2>/dev/null)" ] && rm -rf -- "$stale" 2>/dev/null
87
+ done
88
+
89
+ # The client lease: when the host goes silent without closing the pipe (a
90
+ # wedged relay, not a dead one — EOF exits the read loop), the agent
91
+ # terminates itself after the lease window instead of lingering with its
92
+ # temp dir. The window rides DSH_AGENT_LEASE_MS from the host's pinned
93
+ # environment, set above the host's own idle shutdown, so the lease only
94
+ # fires when the host is gone in every way that matters. A request in
95
+ # flight pauses the lease (the inflight marker) — a long command with no
96
+ # traffic is silence, not absence. Unset (an older host, a manual run)
97
+ # disables the lease: the host keeps the lifetime it always had.
98
+ LEASE_S=0
99
+ case ${DSH_AGENT_LEASE_MS:-} in
100
+ ''|*[!0-9]*) ;;
101
+ *) if [ "$DSH_AGENT_LEASE_MS" -gt 0 ]; then
102
+ LEASE_S=$(( (DSH_AGENT_LEASE_MS + 999) / 1000 ))
103
+ (
104
+ while :; do
105
+ sleep "$LEASE_S" || exit 0
106
+ kill -0 "$$" 2>/dev/null || exit 0
107
+ [ -f "$TMPDIR_AGENT/inflight" ] || kill -s TERM "$$"
108
+ done
109
+ ) &
110
+ WATCHDOG_PID=$!
111
+ fi ;;
112
+ esac
65
113
 
66
114
  # The request currently in flight, for KILL and for the exit trap.
67
115
  CURRENT_PID=
@@ -150,6 +198,10 @@ run_capture() {
150
198
  : >"$OUT_FILE"
151
199
  : >"$ERR_FILE"
152
200
  CURRENT_ID=$req_id
201
+ # The inflight marker pauses the lease watchdog for this request and
202
+ # refreshes the temp dir's mtime for the boot sweep; removing it after the
203
+ # answer re-arms both.
204
+ : >"$TMPDIR_AGENT/inflight"
153
205
  if [ "$mode" = subshell ]; then
154
206
  ( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
155
207
  elif [ -n "$SETSID" ]; then
@@ -192,6 +244,7 @@ run_capture() {
192
244
  printf 'RES|%s|%s|%s|%s|%s|%s\n' "$req_id" "$rc" \
193
245
  "$(b64enc_file "$OUT_FILE")" "$(b64enc_file "$ERR_FILE")" 0 0
194
246
  fi
247
+ rm -f "$TMPDIR_AGENT/inflight"
195
248
  }
196
249
 
197
250
  # --- filesystem substrate (FS frames) -------------------------------------
@@ -488,7 +541,7 @@ fs_dispatch() {
488
541
  esac
489
542
  }
490
543
 
491
- printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
544
+ printf 'HELLO|wsl-agent|%s|%s\n' "$PROTO_VERSION" "$AGENT_DIGEST"
492
545
 
493
546
  while IFS= read -r line; do
494
547
  case $line in
@@ -27,12 +27,11 @@
27
27
  *
28
28
  * @module dsh-plugin-wsl/agent-confined
29
29
  */
30
- import { fileURLToPath } from "node:url";
31
30
  import { bwrapArgvPrefix } from "./bwrap.js";
32
31
  import { toLinuxPath } from "./paths.js";
32
+ import { agentScriptDigest, agentScriptPath } from "./agent-shared.js";
33
33
  import { WslAgent } from "./agent.js";
34
34
 
35
- const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
36
35
  const confinedAgents = new Map();
37
36
 
38
37
  /**
@@ -63,7 +62,8 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
63
62
  agent = new WslAgent({
64
63
  distro,
65
64
  wslPath,
66
- scriptPath: toLinuxPath(AGENT_SCRIPT_PATH),
65
+ scriptPath: toLinuxPath(agentScriptPath),
66
+ expectedDigest: agentScriptDigest,
67
67
  // The prefix is a whole command from the distro's point of view — program,
68
68
  // profile, `--` — because the transport inserts it between
69
69
  // `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  /** The protocol version this host side speaks; the agent must match. */
14
- export const PROTOCOL_VERSION = 3;
14
+ export const PROTOCOL_VERSION = 4;
15
15
 
16
16
  /**
17
17
  * Upper bound on one `read` op's payload, set by the HOST looping over windowed
@@ -133,7 +133,9 @@ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
133
133
  * Parse one line of the agent's stdout.
134
134
  * @param {string} line - a line without its terminator.
135
135
  * @returns {object} one of:
136
- * - `{ type: "hello", name, version }`
136
+ * - `{ type: "hello", name, version, digest }` — `digest` is the sha256 of
137
+ * the script file the agent is executing (empty when it could not hash
138
+ * it); the host compares it against its own shipped copy
137
139
  * - `{ type: "pong" }`
138
140
  * - `{ type: "ack", id }` — the agent dequeued the request for execution;
139
141
  * the host arms its stuck-request watchdog here, so queue time never
@@ -148,8 +150,8 @@ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
148
150
  */
149
151
  export function parseAgentLine(line) {
150
152
  if (line.startsWith("HELLO|")) {
151
- const [, name, version] = line.split("|");
152
- return { type: "hello", name, version: Number(version) };
153
+ const [, name, version, digest] = line.split("|");
154
+ return { type: "hello", name, version: Number(version), digest: digest ?? "" };
153
155
  }
154
156
  if (line === "PONG") return { type: "pong" };
155
157
  if (line.startsWith("ACK|")) return { type: "ack", id: line.slice(4) };
@@ -10,13 +10,26 @@
10
10
  * @module dsh-plugin-wsl/agent-shared
11
11
  */
12
12
 
13
+ import { createHash } from "node:crypto";
14
+ import { readFileSync } from "node:fs";
13
15
  import { fileURLToPath } from "node:url";
14
16
  import { toLinuxPath } from "./paths.js";
15
17
  import { WslAgent } from "./agent.js";
16
18
 
17
19
  const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
20
+ // The script the distro reads must be byte-identical to the copy this
21
+ // package shipped: the deployed layout reads it from the /mnt/c mirror, and
22
+ // the handshake digest is what catches a stale or half-synced runtime copy.
23
+ // Both resident factories (plain and confined) pin it.
24
+ const AGENT_SCRIPT_DIGEST = createHash("sha256").update(readFileSync(AGENT_SCRIPT_PATH)).digest("hex");
18
25
  const sharedAgents = new Map();
19
26
 
27
+ /** The agent script as a host-side path, for factories that need both spellings. */
28
+ export const agentScriptPath = AGENT_SCRIPT_PATH;
29
+
30
+ /** The sha256 of the shipped agent script; every resident verifies its HELLO against it. */
31
+ export const agentScriptDigest = AGENT_SCRIPT_DIGEST;
32
+
20
33
  /**
21
34
  * The shared agent for one distro, created on first use.
22
35
  * @param distro - the distro name.
@@ -25,7 +38,7 @@ const sharedAgents = new Map();
25
38
  export function sharedAgent(distro) {
26
39
  let agent = sharedAgents.get(distro);
27
40
  if (agent === undefined) {
28
- agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH) });
41
+ agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH), expectedDigest: AGENT_SCRIPT_DIGEST });
29
42
  sharedAgents.set(distro, agent);
30
43
  }
31
44
  return agent;
package/lib/agent.js CHANGED
@@ -44,6 +44,11 @@ const HANDSHAKE_TIMEOUT_MS = 15000;
44
44
  /** Extra time the host watchdog waits past the request's own in-distro timeout. */
45
45
  const WATCHDOG_GRACE_MS = 13000;
46
46
 
47
+ /** How far past its host's idle shutdown the agent's own lease runs. The
48
+ * lease fires only when the host is gone in every way that matters (a wedged
49
+ * relay that never delivers EOF); the host's close() normally wins the race. */
50
+ const LEASE_GRACE_MS = 120000;
51
+
47
52
  /** The Windows essentials `wsl.exe` itself needs to start and report cleanly. */
48
53
  const WINDOWS_ESSENTIALS = /^(systemroot|systemdrive|windir|path|pathext|comspec|os|computername)$/i;
49
54
 
@@ -95,11 +100,34 @@ export function pinnedWindowsEnv(parent) {
95
100
  * @param {string} options.scriptPath - the agent script as a Linux path inside
96
101
  * the distro (a `/mnt/<drive>/...` translation of the package's
97
102
  * `agent/wsl-agent.sh`).
103
+ * @param {number} [options.leaseMs] - the agent's own client lease, forwarded
104
+ * through the managed `DSH_` namespace so the script can self-terminate
105
+ * when a wedged relay stops carrying traffic without ever delivering EOF.
98
106
  * @returns {import("node:child_process").ChildProcess} the spawned process.
99
107
  */
100
- function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [] }) {
108
+ /**
109
+ * The spawn environment for one resident, with the client lease folded in.
110
+ * The pinned environment forwards only the managed `DSH_` namespace, and it
111
+ * forwards exactly the names the parent `WSLENV` lists — so the lease rides
112
+ * both: the value on the variable, the name on `WSLENV`. A non-positive
113
+ * lease leaves the environment as the pin built it (the script treats an
114
+ * unset variable as no lease).
115
+ * @param {Record<string, string>} env - the pinned environment.
116
+ * @param {number} leaseMs - the agent's client lease in ms; 0 disables.
117
+ * @returns {Record<string, string>} the environment to spawn with.
118
+ */
119
+ export function agentLeaseEnv(env, leaseMs) {
120
+ if (!(leaseMs > 0)) return env;
121
+ return {
122
+ ...env,
123
+ DSH_AGENT_LEASE_MS: String(leaseMs),
124
+ WSLENV: [env.WSLENV, "DSH_AGENT_LEASE_MS"].filter(Boolean).join(":"),
125
+ };
126
+ }
127
+
128
+ function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [], leaseMs = 0 }) {
101
129
  return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
102
- env: pinnedWindowsEnv(process.env),
130
+ env: agentLeaseEnv(pinnedWindowsEnv(process.env), leaseMs),
103
131
  stdio: ["pipe", "pipe", "pipe"],
104
132
  windowsHide: true,
105
133
  });
@@ -114,9 +142,13 @@ export class WslAgent {
114
142
  * @param {string} config.distro - the distro this agent serves.
115
143
  * @param {string} config.scriptPath - the agent script as a Linux path
116
144
  * inside the distro (see {@link spawnDefault}).
117
- * @param {string} [config.wslPath] - the `wsl.exe` path.
118
- * @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
119
- * @param {string[]} [config.argvPrefix] - arguments inserted between
145
+ * @param {string} [config.wslPath] - the `wsl.exe` path.
146
+ * @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
147
+ * @param {string} [config.expectedDigest] - the sha256 of the agent script
148
+ * as this package shipped it; a HELLO whose digest differs is refused the
149
+ * same way a version mismatch is (the script is read in place, so a stale
150
+ * or half-synced runtime copy is the failure this catches).
151
+ * @param {string[]} [config.argvPrefix] - arguments inserted between
120
152
  * `wsl.exe --exec` and the `sh script` pair — the confinement wrapper
121
153
  * (`bwrap … --`) a confined agent runs its whole lifetime under, so every
122
154
  * filesystem op it performs is kernel-enforced. Empty for the plain agent.
@@ -129,6 +161,7 @@ export class WslAgent {
129
161
  this.wslPath = config.wslPath ?? "wsl.exe";
130
162
  this.argvPrefix = config.argvPrefix ?? [];
131
163
  this.idleMs = config.idleMs ?? 600000;
164
+ this.expectedDigest = config.expectedDigest;
132
165
  /** Hard ceiling on one stdout protocol line; a longer line is a broken or
133
166
  * hostile agent, not data to hold — readline used to accumulate it all. */
134
167
  this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
@@ -179,6 +212,7 @@ export class WslAgent {
179
212
  distro: this.distro,
180
213
  scriptPath: this.scriptPath,
181
214
  argvPrefix: this.argvPrefix,
215
+ leaseMs: this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0,
182
216
  }));
183
217
  const handshakeTimer = setTimeout(() => {
184
218
  this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
@@ -252,9 +286,22 @@ export class WslAgent {
252
286
  switch (message.type) {
253
287
  case "hello": {
254
288
  if (message.name !== AGENT_NAME || message.version !== PROTOCOL_VERSION) {
289
+ // Settle BEFORE killing: the exit event must not overwrite the real
290
+ // reason with the generic handshake message (the fake transports
291
+ // emit exit synchronously inside kill(); a real wsl.exe races it).
255
292
  this.lastError = `agent handshake mismatch: got ${message.name}@${message.version}, want ${AGENT_NAME}@${PROTOCOL_VERSION}`;
293
+ settleStart(new Error(this.lastError));
256
294
  this.process?.kill();
295
+ return;
296
+ }
297
+ if (this.expectedDigest !== undefined && message.digest !== this.expectedDigest) {
298
+ // The script is read in place from the distro-visible path — in the
299
+ // deployed layout a /mnt/c mirror — so a digest that differs from
300
+ // the copy this package shipped means the runtime copy is stale,
301
+ // half-synced, or edited. Fail closed with the fix in the message.
302
+ this.lastError = `agent script digest mismatch: the file the distro read at ${this.scriptPath} hashes to ${message.digest || "(nothing)"}, but this package shipped ${this.expectedDigest} — resync the runtime copy (npm run sync:windows) or reinstall the plugin`;
257
303
  settleStart(new Error(this.lastError));
304
+ this.process?.kill();
258
305
  return;
259
306
  }
260
307
  clearTimeout(handshakeTimer);
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The distro-side traversal strategy for `@` completion — the plugin's
3
+ * ready answer to RFC addendum ① (docs/upstream/rfc-wsl-workspaces.md).
4
+ *
5
+ * ## The seam this consumes
6
+ *
7
+ * `@deepseek-ai/dsh-file-reference-local` funnels every workspace traversal
8
+ * through three module-internal functions: `readWorkspaceRoot` and
9
+ * `readDirectory` (both `node:fs/promises` readdir with `withFileTypes`) and
10
+ * `resolveDisplayDirectory` (a lexical resolve plus a per-segment `lstat`
11
+ * walk that refuses symlink and non-directory components). Everything else —
12
+ * the fuzzy ranking, the bounded index, the generation/invalidation dance,
13
+ * the excluded-directory list — is pure logic over the entries those three
14
+ * return. Addendum ① proposes exposing the three as an injectable strategy
15
+ * with the current implementations as defaults; this module is the consumer
16
+ * side, built and tested against that interface so the takeover lands the
17
+ * day the seam does.
18
+ *
19
+ * It is deliberately NOT wired into cordis.patch.yml yet: with the seam
20
+ * absent, reaching the funnels would mean subclassing past module-internal
21
+ * functions or re-implementing the ranking engine — the behavior-regression
22
+ * path the RFC rules out. The module ships, tested, until upstream speaks.
23
+ *
24
+ * ## Semantics, held faithful where the seam allows
25
+ *
26
+ * - Entries keep upstream's Dirent shape: `{name, isDirectory(), isFile()}`.
27
+ * The distro listing uses `find -printf '%y\t%f'` rather than `ls -1ALp`
28
+ * precisely for this: `%y` reports `l` for symlinks, which the parse skips
29
+ * the way a readdir Dirent is neither file nor directory. `ls -1ALp` would
30
+ * resolve links through and change which candidates exist.
31
+ * - `resolveDisplayDirectory` refuses a symlink or non-directory component
32
+ * with one agent exec that walks the (lexically normalized) segments —
33
+ * the same verdict upstream reaches with N sequential lstats, and unlike
34
+ * `realpath`, refusing is the point, not resolving.
35
+ * - A directory the traversal cannot read yields `[]` (upstream's readdir
36
+ * catch); an aborted caller propagates the abort instead.
37
+ * - The returned absolutes stay in the root's own coordinate system (UNC for
38
+ * a distro workspace), because upstream joins children with the host
39
+ * `node:path` — a POSIX path from this layer would be mangled by it.
40
+ *
41
+ * @module dsh-plugin-wsl/file-reference-wsl
42
+ */
43
+
44
+ import { isWslUnc, posixToUnc, uncToPosix } from "./paths.js";
45
+
46
+ /** Upstream's default cap on one workspace index; the per-directory listing
47
+ * rides the same bound, since one level can't usefully exceed the budget. */
48
+ const DEFAULT_MAX_ENTRIES = 50000;
49
+
50
+ /**
51
+ * Lexically normalize a `/`-separated display directory against its root.
52
+ * Upstream resolves `resolve(root, displayDirectory)` and refuses a result
53
+ * outside the root, so `a/../b` lands on `b` while `..` past the root is
54
+ * refused outright — the walk below never sees a `..` segment.
55
+ *
56
+ * @param {string} displayDirectory - the query text before the fragment, possibly "".
57
+ * @returns {string[] | undefined} the normalized segments, or undefined when
58
+ * the display directory escapes the root.
59
+ */
60
+ export function normalizeDisplaySegments(displayDirectory) {
61
+ const segments = [];
62
+ for (const segment of String(displayDirectory).split("/")) {
63
+ if (segment === "" || segment === ".") continue;
64
+ if (segment === "..") {
65
+ if (segments.length === 0) return undefined;
66
+ segments.pop();
67
+ continue;
68
+ }
69
+ segments.push(segment);
70
+ }
71
+ return segments;
72
+ }
73
+
74
+ /**
75
+ * The distro argv for one directory listing: `find -printf '%y\t%f'` over the
76
+ * contained directory. The containment guard is the same shape the picker and
77
+ * workspace-files use — `realpath` the requested directory, refuse anything
78
+ * the root does not contain — and the `%y` type char is what keeps symlink
79
+ * semantics identical to a readdir Dirent (skipped, not followed).
80
+ *
81
+ * @param {string} linuxRoot - the workspace root, POSIX spelling.
82
+ * @param {string} linuxDir - the directory to list, POSIX spelling.
83
+ * @param {number} maxEntries - the listing cap (head -n bound).
84
+ * @returns {string[]} the agent exec argv.
85
+ */
86
+ export function traversalArgv(linuxRoot, linuxDir, maxEntries) {
87
+ const script = [
88
+ "root=$1 dir=$2 max=$3",
89
+ 'real=$(realpath -L -- "$dir") || exit 2',
90
+ 'case "$real" in "$root") ;; "$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac',
91
+ '[ -d "$real" ] || { echo "NOTDIR:$real" >&2; exit 4; }',
92
+ 'find "$real" -mindepth 1 -maxdepth 1 -printf \'%y\\t%f\\n\' | head -n "$max"',
93
+ ].join("\n");
94
+ return ["sh", "-c", script, "sh", linuxRoot, linuxDir, maxEntries];
95
+ }
96
+
97
+ /**
98
+ * Parse one `%y\t%f` listing into upstream's Dirent-like entries.
99
+ * `d` is a directory and `f` a file; every other type char — `l` for
100
+ * symlinks, devices, sockets — is skipped, matching a readdir Dirent.
101
+ *
102
+ * @param {string} stdout - the raw listing.
103
+ * @returns {{name: string, isDirectory: () => boolean, isFile: () => boolean}[]}
104
+ */
105
+ export function parseTraversalListing(stdout) {
106
+ const entries = [];
107
+ for (const line of String(stdout).split("\n")) {
108
+ const tab = line.indexOf("\t");
109
+ if (tab <= 0) continue;
110
+ const kind = line.slice(0, tab);
111
+ const name = line.slice(tab + 1);
112
+ if (name.length === 0) continue;
113
+ if (kind !== "d" && kind !== "f") continue;
114
+ entries.push({
115
+ name,
116
+ isDirectory: () => kind === "d",
117
+ isFile: () => kind === "f",
118
+ });
119
+ }
120
+ return entries;
121
+ }
122
+
123
+ /**
124
+ * The argv for the per-segment walk behind `resolveDisplayDirectory`: each
125
+ * segment must exist, be a directory, and not be a symlink — upstream's
126
+ * lstat verdicts, checked in one exec. Segments ride as argv elements, never
127
+ * interpolated into the script.
128
+ *
129
+ * @param {string} linuxRoot - the workspace root, POSIX spelling.
130
+ * @param {string[]} segments - the normalized display segments below the root.
131
+ * @returns {string[]} the agent exec argv.
132
+ */
133
+ export function resolveDirectoryArgv(linuxRoot, segments) {
134
+ const script = [
135
+ "root=$1",
136
+ 'cur="$root"',
137
+ 'for seg in "${@:2}"; do',
138
+ ' cur="$cur/$seg"',
139
+ ' if [ -L "$cur" ]; then echo "SYMLINK:$cur" >&2; exit 5; fi',
140
+ ' [ -d "$cur" ] || { echo "NOTDIR:$cur" >&2; exit 4; }',
141
+ "done",
142
+ 'printf \'%s\\n\' "$cur"',
143
+ ].join("\n");
144
+ return ["sh", "-c", script, "sh", linuxRoot, ...segments];
145
+ }
146
+
147
+ /**
148
+ * The host-default traversal — the node:fs implementations the seam keeps
149
+ * as upstream's defaults, replicated here so the prototype answers host
150
+ * workspaces without the seam and so tests can inject substitutes for it.
151
+ * (Upstream owns the real defaults; this copy exists only until the takeover.)
152
+ *
153
+ * @param {object} deps - the node:fs/promises functions, injectable for tests.
154
+ * @returns the strategy object with the upstream default semantics.
155
+ */
156
+ export function hostFileReferenceTraversal(deps) {
157
+ const read = async (absolute, signal) => {
158
+ signal.throwIfAborted();
159
+ try {
160
+ const entries = await deps.readdir(absolute, { withFileTypes: true });
161
+ signal.throwIfAborted();
162
+ return entries;
163
+ } catch (error) {
164
+ signal.throwIfAborted();
165
+ throw error;
166
+ }
167
+ };
168
+ return {
169
+ readWorkspaceRoot: read,
170
+ readDirectory: async (absolute, signal) => {
171
+ signal.throwIfAborted();
172
+ try {
173
+ const entries = await deps.readdir(absolute, { withFileTypes: true });
174
+ signal.throwIfAborted();
175
+ return entries;
176
+ } catch {
177
+ signal.throwIfAborted();
178
+ return [];
179
+ }
180
+ },
181
+ resolveDisplayDirectory: async (root, displayDirectory, signal) => {
182
+ const segments = normalizeDisplaySegments(displayDirectory);
183
+ if (segments === undefined) return undefined;
184
+ let current = root;
185
+ for (const segment of segments) {
186
+ signal.throwIfAborted();
187
+ current = `${current}${current.endsWith("/") ? "" : "/"}${segment}`;
188
+ try {
189
+ const status = await deps.lstat(current);
190
+ signal.throwIfAborted();
191
+ if (status.isSymbolicLink() || !status.isDirectory()) return undefined;
192
+ } catch (error) {
193
+ signal.throwIfAborted();
194
+ return undefined;
195
+ }
196
+ }
197
+ return current;
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * The distro-routed traversal strategy. Each method routes by coordinate:
204
+ * a `\\wsl.localhost\<distro>` UNC rides the resident agent (the search
205
+ * root and every absolute upstream passes stay in the UNC form it joined
206
+ * them with); anything else defers to the default traversal. The search
207
+ * root doubles as the containment root for the distro listings' guard, so
208
+ * the seam wires one strategy per `WorkspaceFileSearch` — exactly where
209
+ * `WorkspaceFileSearch(root, config, traversal)` would take it.
210
+ *
211
+ * @param {object} options
212
+ * @param {string} options.root - the search root, the UNC form upstream passes.
213
+ * @param {(distro: string) => object} options.runnerFor - the agent runner
214
+ * for one distro; tests substitute a fake (a real wiring passes sharedAgent).
215
+ * @param {object} options.defaultTraversal - the host-default strategy.
216
+ * @param {number} [options.maxEntries] - the listing cap, upstream's index budget.
217
+ * @returns the strategy object the seam's `WorkspaceFileSearch` would take.
218
+ */
219
+ export function wslFileReferenceTraversal({ root, runnerFor, defaultTraversal, maxEntries = DEFAULT_MAX_ENTRIES }) {
220
+ if (!isWslUnc(root)) return defaultTraversal;
221
+ const rootCoords = uncToPosix(root);
222
+ const rootLinux = rootCoords.linuxPath;
223
+
224
+ const listDistro = async (absolute, signal, onRefusal) => {
225
+ signal.throwIfAborted();
226
+ const { linuxPath } = uncToPosix(absolute);
227
+ try {
228
+ const result = await runnerFor(rootCoords.distro).exec({
229
+ cwd: "/",
230
+ argv: traversalArgv(rootLinux, linuxPath, maxEntries),
231
+ maxOutputBytes: 4_000_000,
232
+ timeoutMs: 30_000,
233
+ signal,
234
+ });
235
+ signal.throwIfAborted();
236
+ if (result.exitCode !== 0) {
237
+ return onRefusal(new Error(`listing "${linuxPath}" failed: ${String(result.stderr).trim() || `exit ${result.exitCode}`}`));
238
+ }
239
+ return parseTraversalListing(result.stdout);
240
+ } catch (error) {
241
+ signal.throwIfAborted();
242
+ return onRefusal(error);
243
+ }
244
+ };
245
+
246
+ return {
247
+ readWorkspaceRoot(absolute, signal) {
248
+ if (!isWslUnc(absolute)) return defaultTraversal.readWorkspaceRoot(absolute, signal);
249
+ return listDistro(absolute, signal, (error) => {
250
+ throw error;
251
+ });
252
+ },
253
+
254
+ readDirectory(absolute, signal) {
255
+ if (!isWslUnc(absolute)) return defaultTraversal.readDirectory(absolute, signal);
256
+ return listDistro(absolute, signal, () => []);
257
+ },
258
+
259
+ async resolveDisplayDirectory(rootOf, displayDirectory, signal) {
260
+ signal.throwIfAborted();
261
+ if (!isWslUnc(rootOf)) return defaultTraversal.resolveDisplayDirectory(rootOf, displayDirectory, signal);
262
+ const segments = normalizeDisplaySegments(displayDirectory);
263
+ if (segments === undefined) return undefined;
264
+ if (segments.length === 0) return root;
265
+ try {
266
+ const result = await runnerFor(rootCoords.distro).exec({
267
+ cwd: "/",
268
+ argv: resolveDirectoryArgv(rootLinux, segments),
269
+ maxOutputBytes: 4_000_000,
270
+ timeoutMs: 30_000,
271
+ signal,
272
+ });
273
+ signal.throwIfAborted();
274
+ if (result.exitCode !== 0) return undefined;
275
+ return posixToUnc(rootCoords.distro, result.stdout.trim());
276
+ } catch {
277
+ signal.throwIfAborted();
278
+ return undefined;
279
+ }
280
+ },
281
+ };
282
+ }
283
+
284
+ export default wslFileReferenceTraversal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -14,28 +14,29 @@
14
14
  "main": "lib/shell.js",
15
15
  "exports": {
16
16
  ".": "./lib/shell.js",
17
- "./shell": "./lib/shell.js",
17
+ "./agent": "./lib/agent.js",
18
+ "./auto-preset": "./lib/auto-preset.js",
19
+ "./auto-preset/locale/en.json": "./locale/auto-preset/en.json",
20
+ "./auto-preset/locale/zh.json": "./locale/auto-preset/zh.json",
21
+ "./file-reference-wsl": "./lib/file-reference-wsl.js",
18
22
  "./fs": "./lib/fs.js",
19
- "./subprocess": "./lib/subprocess.js",
20
- "./picker": "./lib/picker.js",
21
- "./workspace-files-wsl": "./lib/workspace-files-wsl.js",
22
23
  "./fs-routing": "./lib/fs-routing.js",
23
- "./auto-preset": "./lib/auto-preset.js",
24
- "./shell-env": "./lib/shell-env.js",
25
- "./wsl": "./lib/wsl.js",
26
- "./paths": "./lib/paths.js",
27
- "./agent": "./lib/agent.js",
28
- "./package.json": "./package.json",
29
24
  "./locale/en.json": "./locale/en.json",
30
25
  "./locale/zh.json": "./locale/zh.json",
31
- "./subprocess/locale/en.json": "./locale/subprocess/en.json",
32
- "./subprocess/locale/zh.json": "./locale/subprocess/zh.json",
26
+ "./package.json": "./package.json",
27
+ "./paths": "./lib/paths.js",
28
+ "./picker": "./lib/picker.js",
33
29
  "./picker/locale/en.json": "./locale/picker/en.json",
34
30
  "./picker/locale/zh.json": "./locale/picker/zh.json",
31
+ "./shell": "./lib/shell.js",
32
+ "./shell-env": "./lib/shell-env.js",
35
33
  "./shell-env/locale/en.json": "./locale/shell-env/en.json",
36
34
  "./shell-env/locale/zh.json": "./locale/shell-env/zh.json",
37
- "./auto-preset/locale/en.json": "./locale/auto-preset/en.json",
38
- "./auto-preset/locale/zh.json": "./locale/auto-preset/zh.json"
35
+ "./subprocess": "./lib/subprocess.js",
36
+ "./subprocess/locale/en.json": "./locale/subprocess/en.json",
37
+ "./subprocess/locale/zh.json": "./locale/subprocess/zh.json",
38
+ "./workspace-files-wsl": "./lib/workspace-files-wsl.js",
39
+ "./wsl": "./lib/wsl.js"
39
40
  },
40
41
  "dsh": {
41
42
  "bundle": {
@@ -65,9 +66,9 @@
65
66
  ],
66
67
  "scripts": {
67
68
  "test": "npm run lint:style && npm run test:syntax && npm run test:unit",
68
- "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/fs.boot.test.mjs test/shell.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs",
69
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/file-reference-wsl.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/fs.boot.test.mjs test/shell.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs",
69
70
  "test:syntax": "node test/syntax.mjs",
70
- "test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/fsio-text.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs",
71
+ "test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/file-reference-wsl.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/fsio-text.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs",
71
72
  "lint:style": "node test/style.mjs",
72
73
  "prepublishOnly": "npm test",
73
74
  "probe": "bash test/probe/run.sh",