dsh-plugin-wsl-env 0.1.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 +274 -0
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/cordis.patch.yml +306 -0
- package/examples/profile.cordis.patch.yml +36 -0
- package/lib/auto-preset.js +198 -0
- package/lib/fs.js +5 -0
- package/lib/index.js +871 -0
- package/lib/listing.js +112 -0
- package/lib/paths.js +265 -0
- package/lib/picker.js +350 -0
- package/lib/sandbox.js +162 -0
- package/lib/shell-env.js +74 -0
- package/lib/shell.js +5 -0
- package/lib/subprocess.js +230 -0
- package/lib/wsl.js +320 -0
- package/package.json +103 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-wsl — run a DeepSeek Harness session against a WSL distro.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek Harness is a Cordis application whose capabilities are exposed as
|
|
5
|
+
* service seams: `ctx.fs` (filesystem), `ctx.shell` (command execution),
|
|
6
|
+
* `ctx.sandbox` (confinement), `ctx.directoryPicker`, `ctx.workspaceRegistry`.
|
|
7
|
+
* The model-facing tools (`dsh-tool-fs`, `dsh-tool-fs-search`, `dsh-tool-bash`)
|
|
8
|
+
* consume those seams and never talk to a filesystem or a shell directly. A WSL
|
|
9
|
+
* integration is therefore a seam provider, not a new tool.
|
|
10
|
+
*
|
|
11
|
+
* This package provides two of them:
|
|
12
|
+
*
|
|
13
|
+
* - {@link WslShellExecutor} registers `ctx.shell` and runs every command
|
|
14
|
+
* inside the distro as `wsl.exe -d <distro> --cd <linux dir> --exec <shell> -lc <cmd>`,
|
|
15
|
+
* where `<shell>` is the distro user's login shell (not a hardcoded bash).
|
|
16
|
+
* - {@link WslFileSystem} registers `ctx.fs` and maps Linux paths onto the
|
|
17
|
+
* distro's UNC share so the host's own fs stack (including the packaged
|
|
18
|
+
* ripgrep used by `dsh-tool-fs-search`) reads and writes real distro files.
|
|
19
|
+
*
|
|
20
|
+
* ## The one constraint that shapes everything
|
|
21
|
+
*
|
|
22
|
+
* On Windows, DSH confines commands with `dsh-sandbox-windows-acl`: a
|
|
23
|
+
* restricted, low-integrity token plus a capability-SID write allowlist. That
|
|
24
|
+
* token cannot reach WSL at all — `wsl.exe` fails with `Wsl/E_ACCESSDENIED` and
|
|
25
|
+
* the UNC share `\\wsl.localhost\<distro>` reports access denied. Both work
|
|
26
|
+
* normally outside the sandbox.
|
|
27
|
+
*
|
|
28
|
+
* The confinement is applied by the *sandboxing* executor (`pwsh-sandbox`),
|
|
29
|
+
* which wraps the argv through `ctx.sandbox.confine`. This plugin therefore
|
|
30
|
+
* subclasses the NON-sandboxing `LocalBashExecutor`, and inherits
|
|
31
|
+
* `sandboxMode === undefined` from the seam — which is the honest capability
|
|
32
|
+
* fact: the tool layer reads it and advertises that these commands are not
|
|
33
|
+
* confined. WSL access is inherently outside the harness's file sandbox; do not
|
|
34
|
+
* paper over that by reporting a mode this provider does not enforce.
|
|
35
|
+
*
|
|
36
|
+
* @module dsh-plugin-wsl
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { rename } from "node:fs/promises";
|
|
40
|
+
import z from "@deepseek-ai/schemastery";
|
|
41
|
+
import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
|
|
42
|
+
import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
|
|
43
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
44
|
+
import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
|
|
45
|
+
import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix } from "./paths.js";
|
|
46
|
+
import { WslSandbox } from "./sandbox.js";
|
|
47
|
+
// `runCapture` and distro discovery live in ./wsl.js so the directory picker
|
|
48
|
+
// can use them without pulling this file's executor and filesystem peers in.
|
|
49
|
+
import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs } from "./wsl.js";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every model shell call gets a rebuilt managed `DSH_*` namespace (see
|
|
53
|
+
* `dsh-shell-env` / `ctx.shellEnv`). Those names exist only in the Windows-side
|
|
54
|
+
* process, so unless they are listed in WSLENV they never reach the distro and
|
|
55
|
+
* the model sees none of its environment facts.
|
|
56
|
+
*/
|
|
57
|
+
/**
|
|
58
|
+
* Of the managed facts, these two carry Windows paths. WSL's `WSLENV` `/p` flag
|
|
59
|
+
* translates them on the way in, so a command inside the distro gets a usable
|
|
60
|
+
* `/mnt/c/...` path instead of `C:\...`. `DSH_WSL_HOME` is deliberately absent:
|
|
61
|
+
* it is already a POSIX path, and translating it would corrupt it.
|
|
62
|
+
*/
|
|
63
|
+
const DSH_ENV_PREFIX = "DSH_";
|
|
64
|
+
const PATH_TRANSLATED = new Set([`${DSH_ENV_PREFIX}HOME`, `${DSH_ENV_PREFIX}PROFILE_DIR`]);
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/** Env names that are meaningful on the Linux side and safe to forward. */
|
|
68
|
+
const SAFE_FORWARD = ["NO_COLOR", "TERM", "PAGER", "GIT_PAGER", "LANG", "LC_ALL"];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The configured default workdir, or an empty string when the operator left it
|
|
72
|
+
* unset. `cwd` is a volatile field on both providers, so it is read through
|
|
73
|
+
* `.get()`, and it may be absent entirely.
|
|
74
|
+
*
|
|
75
|
+
* @param config - a provider's config.
|
|
76
|
+
* @returns the configured directory, trimmed, or `""`.
|
|
77
|
+
*/
|
|
78
|
+
function configuredCwd(config) {
|
|
79
|
+
return String(config.cwd?.get?.() ?? "").trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The distro user's home as a Linux path, cached on the provider instance whose
|
|
84
|
+
* `distro`/`wslPath` config it belongs to.
|
|
85
|
+
*
|
|
86
|
+
* An empty `cwd` cannot mean the shipped default (`process.cwd()`, a Windows
|
|
87
|
+
* directory no `wsl.exe --cd` accepts) and it must not mean a hardcoded author
|
|
88
|
+
* path either, so both providers agree on this instead: the distro's own home,
|
|
89
|
+
* asked of the distro once per instance.
|
|
90
|
+
*
|
|
91
|
+
* @param owner - a WslShellExecutor or WslFileSystem.
|
|
92
|
+
* @returns an absolute Linux path.
|
|
93
|
+
*/
|
|
94
|
+
async function distroHome(owner) {
|
|
95
|
+
if (owner.resolvedHome === undefined) {
|
|
96
|
+
const distro = await owner.distro();
|
|
97
|
+
owner.resolvedHome = await linuxHomePath(distro, { wslPath: owner.config.wslPath });
|
|
98
|
+
}
|
|
99
|
+
return owner.resolvedHome;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The roots a `workspace-write` policy may write under, in the vocabulary this
|
|
104
|
+
* backend's targets are spelled in.
|
|
105
|
+
*
|
|
106
|
+
* Upstream's {@link writableRoots} grants the workspace plus the platform temp
|
|
107
|
+
* areas (`/tmp`, `os.tmpdir()`). A session whose workspace lives inside a
|
|
108
|
+
* distro speaks UNC, where neither host spelling matches anything, and the temp
|
|
109
|
+
* area that world actually exposes is the distro's own `/tmp` — so that is what
|
|
110
|
+
* is granted instead, and the Windows temp dir deliberately is NOT: it lies
|
|
111
|
+
* outside the execution world the policy describes. A session whose workspace
|
|
112
|
+
* is a Windows folder keeps upstream's grant verbatim.
|
|
113
|
+
*
|
|
114
|
+
* @param policy - the per-call file-effect policy.
|
|
115
|
+
* @param distro - the distro a UNC workspace belongs to.
|
|
116
|
+
* @returns canonical writable roots; empty exactly under `read-only`.
|
|
117
|
+
*/
|
|
118
|
+
function wslWritableRoots(policy, distro) {
|
|
119
|
+
if (policy.mode !== "workspace-write") return [];
|
|
120
|
+
const roots = new Set([canonicalPath(policy.workspaceRoot)]);
|
|
121
|
+
if (isWslUnc(policy.workspaceRoot)) {
|
|
122
|
+
roots.add(canonicalPath(posixToUnc(distro, "/tmp")));
|
|
123
|
+
} else {
|
|
124
|
+
for (const root of writableRoots(policy)) roots.add(root);
|
|
125
|
+
}
|
|
126
|
+
return [...roots];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bash executor that runs every command inside a WSL distro.
|
|
131
|
+
*
|
|
132
|
+
* Extends the unconfined `LocalBashExecutor`, so command defaulting, deadline
|
|
133
|
+
* handling, output caps, spill files and background-process management are the
|
|
134
|
+
* shipped implementation's; this subclass replaces only the argv (prefix with
|
|
135
|
+
* `wsl.exe`) and the process working directory (wsl.exe must be started from a
|
|
136
|
+
* Windows directory, while the Linux directory rides on `--cd`).
|
|
137
|
+
*/
|
|
138
|
+
export class WslShellExecutor extends LocalBashExecutor {
|
|
139
|
+
static inject = ["subprocess", "sandboxPolicy"];
|
|
140
|
+
|
|
141
|
+
static Config = z.object({
|
|
142
|
+
/** Distro name; empty means WSL's own default distro. */
|
|
143
|
+
distro: z.string().default(""),
|
|
144
|
+
/** Path to `wsl.exe`; overridable for a non-standard install. */
|
|
145
|
+
wslPath: z.string().default("wsl.exe"),
|
|
146
|
+
/**
|
|
147
|
+
* The shell commands run in. Empty means "resolve the login shell of the
|
|
148
|
+
* distro's user" — hardcoding bash silently ignores a user whose passwd
|
|
149
|
+
* entry points at zsh, fish, dash, or anything else, losing their PATH and
|
|
150
|
+
* rc setup. Set an absolute path inside the distro to pin one.
|
|
151
|
+
*/
|
|
152
|
+
shell: z.string().default(""),
|
|
153
|
+
/**
|
|
154
|
+
* Login-shell semantics (`<shell> -lc`) source the profile scripts, which is
|
|
155
|
+
* what makes an interactive WSL setup's PATH (nvm, cargo, pyenv) visible.
|
|
156
|
+
* Set false for a fast, fully deterministic `<shell> -c`. Applies only to the
|
|
157
|
+
* POSIX family — see `shellArgs` for why other shells get a bare `-c`.
|
|
158
|
+
*/
|
|
159
|
+
loginShell: z.boolean().default(true),
|
|
160
|
+
/**
|
|
161
|
+
* Confine every command inside the distro with `bwrap`, and report the
|
|
162
|
+
* policy's mode through {@link sandboxMode}. Fails closed: a distro without
|
|
163
|
+
* a usable `bwrap` produces `SANDBOX_UNAVAILABLE` rather than an unconfined
|
|
164
|
+
* run. `false` restores the pre-sandbox behaviour exactly — commands run
|
|
165
|
+
* unconfined and `sandboxMode` is `undefined`, so the model is told so.
|
|
166
|
+
*/
|
|
167
|
+
sandbox: z.boolean().default(true),
|
|
168
|
+
/**
|
|
169
|
+
* Default workdir for a request that names none; accepts `/home/andy/proj`,
|
|
170
|
+
* a UNC path, or `C:\...`. Empty — the default — means the distro user's
|
|
171
|
+
* home, resolved inside the distro.
|
|
172
|
+
*/
|
|
173
|
+
cwd: z.string().volatile(),
|
|
174
|
+
/** Windows directory the `wsl.exe` process itself starts in. */
|
|
175
|
+
hostCwd: z.string().volatile(),
|
|
176
|
+
/** Extra env names to forward into the distro through WSLENV. */
|
|
177
|
+
forwardEnv: z.array(z.string()).default([]),
|
|
178
|
+
timeoutMs: z.number().default(12e4).volatile(),
|
|
179
|
+
maxTimeoutMs: z.number().default(6e5).volatile(),
|
|
180
|
+
maxOutputBytes: z.number().default(64e3).volatile(),
|
|
181
|
+
maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
|
|
182
|
+
graceMs: z.number().default(3e3).volatile(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
constructor(ctx, config) {
|
|
186
|
+
super(ctx, config);
|
|
187
|
+
/** Cached default distro, resolved once from `wsl.exe -l -q`. */
|
|
188
|
+
this.resolvedDistro = config.distro;
|
|
189
|
+
/** Cached distro-user login shell, resolved once. */
|
|
190
|
+
this.resolvedShell = config.shell;
|
|
191
|
+
/** Cached distro-user home, the workdir when `cwd` is empty. */
|
|
192
|
+
this.resolvedHome = undefined;
|
|
193
|
+
/** The default mode — the capability fact the tool layer reads. */
|
|
194
|
+
this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
|
|
195
|
+
/** The distro-side confinement, or nothing when the operator opted out. */
|
|
196
|
+
this.sandbox = config.sandbox ? new WslSandbox({ wslPath: config.wslPath }) : undefined;
|
|
197
|
+
/**
|
|
198
|
+
* Per-process confinement facts retained until settlement. Overlapping calls
|
|
199
|
+
* can carry different policies, so a shared latest-wrap value would classify
|
|
200
|
+
* a process against the wrong facts. Unconfined processes have no entry.
|
|
201
|
+
*/
|
|
202
|
+
this.processFacts = new Map();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The mode this executor confines with, or `undefined` when it does not
|
|
207
|
+
* confine at all. `dsh-tool-bash` reads exactly this to decide whether to
|
|
208
|
+
* advertise the escalation field, so it must disappear together with the
|
|
209
|
+
* enforcement — never claim a boundary that is not applied.
|
|
210
|
+
* @returns the deployment default mode, or `undefined` when unconfined.
|
|
211
|
+
*/
|
|
212
|
+
get sandboxMode() {
|
|
213
|
+
return this.mode;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The distro every command runs in, resolving WSL's default on first use.
|
|
218
|
+
* @returns the distro name.
|
|
219
|
+
*/
|
|
220
|
+
async distro() {
|
|
221
|
+
if (this.resolvedDistro.length === 0) this.resolvedDistro = await defaultDistro({ wslPath: this.config.wslPath });
|
|
222
|
+
return this.resolvedDistro;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The shell every command runs in: the configured one, else the login shell of
|
|
227
|
+
* the distro user, resolved once and cached.
|
|
228
|
+
* @returns an absolute path to the shell inside the distro.
|
|
229
|
+
*/
|
|
230
|
+
async shellName() {
|
|
231
|
+
// Consumed by the shell tool after upstream change A: it names and describes
|
|
232
|
+
// itself from the executor, so a zsh distro is never presented as bash.
|
|
233
|
+
const shell = await this.shell();
|
|
234
|
+
const base = String(shell ?? "").split("/").pop() ?? "";
|
|
235
|
+
const name = base.replace(/\.exe$/i, "").replace(/^[-.]+/, "");
|
|
236
|
+
return /^[a-z0-9_+-]+$/i.test(name) ? name : "bash";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async shell() {
|
|
240
|
+
if (this.resolvedShell.length === 0) {
|
|
241
|
+
const distro = await this.distro();
|
|
242
|
+
this.resolvedShell = await defaultShell(distro, { wslPath: this.config.wslPath });
|
|
243
|
+
}
|
|
244
|
+
return this.resolvedShell;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Windows directory the `wsl.exe` process is started from. */
|
|
248
|
+
hostCwd() {
|
|
249
|
+
return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The argv that runs INSIDE the distro for one spec: the resolved shell, its
|
|
254
|
+
* login/command flags, and the command string. This is the argv a
|
|
255
|
+
* distro-side sandbox wraps, which is why it is built separately from the
|
|
256
|
+
* `wsl.exe` invocation around it.
|
|
257
|
+
*
|
|
258
|
+
* @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
|
|
259
|
+
* @param shell - the resolved distro shell.
|
|
260
|
+
* @returns the distro-side argv.
|
|
261
|
+
*/
|
|
262
|
+
distroArgv(spec, shell) {
|
|
263
|
+
return [shell, ...shellArgs(shell, this.config.loginShell), spec.command];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the `wsl.exe` argv for one resolved spec. The command string is
|
|
268
|
+
* passed as a single argv element to the resolved shell, so there is no
|
|
269
|
+
* quoting layer of our own; Node's argv array reaches `wsl.exe` verbatim.
|
|
270
|
+
*
|
|
271
|
+
* `--exec` is load-bearing — see the comment inside.
|
|
272
|
+
*
|
|
273
|
+
* @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
|
|
274
|
+
* @param distro - the resolved distro name.
|
|
275
|
+
* @param shell - the resolved distro shell.
|
|
276
|
+
* @param inner - the already-confined distro argv, when confinement applies.
|
|
277
|
+
* @returns the argv to spawn.
|
|
278
|
+
*/
|
|
279
|
+
argv(spec, distro, shell, inner) {
|
|
280
|
+
const distroArgs = distro.length > 0 ? ["-d", distro] : [];
|
|
281
|
+
return [
|
|
282
|
+
this.config.wslPath,
|
|
283
|
+
...distroArgs,
|
|
284
|
+
"--cd",
|
|
285
|
+
toLinuxPath(spec.workdir, { distro }),
|
|
286
|
+
// `--exec` is essential, not decoration. Without it wsl.exe hands the
|
|
287
|
+
// command line to the distro's DEFAULT SHELL, which expands it once before
|
|
288
|
+
// our own shell ever sees it: `x=1; echo "$x"` arrives as `x=1; echo ""`,
|
|
289
|
+
// `$?` becomes the outer shell's status, and `$(...)`, `${...}`, `$1` and
|
|
290
|
+
// backticks are eaten the same way. With `--exec` the argv is passed
|
|
291
|
+
// straight through, so the command string reaches the shell verbatim.
|
|
292
|
+
//
|
|
293
|
+
// Note the shell is named explicitly here even though `--exec` plus the
|
|
294
|
+
// distro's default shell would look equivalent: without `--exec` we get the
|
|
295
|
+
// double parse above, so the shell has to be resolved by us instead.
|
|
296
|
+
"--exec",
|
|
297
|
+
...(inner ?? this.distroArgv(spec, shell)),
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Override only the spawn's working directory and environment. `spec.workdir`
|
|
303
|
+
* is a Linux path, which a Windows CreateProcess call cannot use as `cwd`.
|
|
304
|
+
*
|
|
305
|
+
* @param spec - resolved execution settings.
|
|
306
|
+
* @param argv - the argv from {@link WslShellExecutor.argv}.
|
|
307
|
+
* @param stdoutMaxBytes - per-call stdout cap.
|
|
308
|
+
* @param signal - the spawn cancellation signal.
|
|
309
|
+
* @returns the subprocess spawn spec.
|
|
310
|
+
*/
|
|
311
|
+
spawnSpec(spec, argv, stdoutMaxBytes, signal) {
|
|
312
|
+
const base = super.spawnSpec(spec, argv, stdoutMaxBytes, signal);
|
|
313
|
+
const forward = new Set([...SAFE_FORWARD, ...this.config.forwardEnv]);
|
|
314
|
+
const forwarded = Object.keys(base.env ?? {}).filter(
|
|
315
|
+
(name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX),
|
|
316
|
+
);
|
|
317
|
+
return {
|
|
318
|
+
...base,
|
|
319
|
+
cwd: this.hostCwd(),
|
|
320
|
+
env: {
|
|
321
|
+
...base.env,
|
|
322
|
+
// WSL imports only the names listed in WSLENV. The managed DSH_* facts are
|
|
323
|
+
// included by prefix so every contributed variable rides along. PATH is deliberately
|
|
324
|
+
// never listed: putting it here would replace the distro's PATH with
|
|
325
|
+
// the Windows one instead of appending the interop entries WSL adds
|
|
326
|
+
// on its own.
|
|
327
|
+
...(forwarded.length > 0 ? { WSLENV: forwarded.map((name) => (PATH_TRANSLATED.has(name) ? `${name}/p` : name)).join(":") } : {}),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Resolve a request into a spec, replacing the parent's `process.cwd()`
|
|
334
|
+
* fallback with this provider's distro-home marker and stamping the per-call
|
|
335
|
+
* sandbox policy.
|
|
336
|
+
*
|
|
337
|
+
* The seam requires this step to be synchronous — the tool layer calls
|
|
338
|
+
* `ctx.shell.execute(ctx.shell.resolve(…))` with no `await` between them — so
|
|
339
|
+
* neither the home nor a confinement decision can be made here. An empty
|
|
340
|
+
* `workdir` is the home marker, filled in {@link withDefaultWorkdir}, and the
|
|
341
|
+
* policy travels on the spec to {@link execute}, which awaits the wrap.
|
|
342
|
+
*
|
|
343
|
+
* @param request - the caller's request.
|
|
344
|
+
* @returns the fully-resolved spec.
|
|
345
|
+
*/
|
|
346
|
+
resolve(request) {
|
|
347
|
+
const spec = super.resolve(request);
|
|
348
|
+
const sandboxPolicy = this.sandbox === undefined ? undefined : (request?.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve());
|
|
349
|
+
const needsHome = request?.workdir === undefined && configuredCwd(this.config).length === 0;
|
|
350
|
+
if (!needsHome && sandboxPolicy === undefined) return spec;
|
|
351
|
+
return {
|
|
352
|
+
...spec,
|
|
353
|
+
...(needsHome ? { workdir: "" } : {}),
|
|
354
|
+
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The spec to execute, with an empty `workdir` filled from the distro user's
|
|
360
|
+
* home. That is one `sh -c 'printf %s "$HOME"'` per provider instance, cached,
|
|
361
|
+
* and it happens only for a request that named no directory and had no
|
|
362
|
+
* configured default.
|
|
363
|
+
*
|
|
364
|
+
* @param spec - the resolved spec.
|
|
365
|
+
* @returns the spec to execute.
|
|
366
|
+
*/
|
|
367
|
+
async withDefaultWorkdir(spec) {
|
|
368
|
+
if (String(spec?.workdir ?? "").trim().length > 0) return spec;
|
|
369
|
+
return { ...spec, workdir: await distroHome(this) };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Decorate a handle's foreground projection in place, memoized once. The
|
|
374
|
+
* handle keeps its identity — never wrapped in a second object — because the
|
|
375
|
+
* per-process facts and `onProcessDone` key on the exact instance.
|
|
376
|
+
*
|
|
377
|
+
* @param ex - the execution handle to decorate.
|
|
378
|
+
* @param map - maps the settled result.
|
|
379
|
+
* @param mapError - maps a rejection, when the caller has one.
|
|
380
|
+
* @returns the same handle.
|
|
381
|
+
*/
|
|
382
|
+
static decorateResult(ex, map, mapError) {
|
|
383
|
+
const base = ex.result.bind(ex);
|
|
384
|
+
let decorated;
|
|
385
|
+
ex.result = () => {
|
|
386
|
+
decorated ??= base().then(map, mapError);
|
|
387
|
+
return decorated;
|
|
388
|
+
};
|
|
389
|
+
return ex;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Stamp the per-process sandbox facts before `done` settles, so a background
|
|
394
|
+
* process reports the same mode, denial and enforcement a foreground run
|
|
395
|
+
* would. Signal deaths are not denials.
|
|
396
|
+
*/
|
|
397
|
+
onProcessDone(proc, stderr, providerRejected, providerError) {
|
|
398
|
+
const facts = this.processFacts.get(proc);
|
|
399
|
+
if (facts !== undefined) {
|
|
400
|
+
this.processFacts.delete(proc);
|
|
401
|
+
// A provider rejection exposes no public failure stage; attribute it to
|
|
402
|
+
// the confinement runner only when the error independently names argv[0].
|
|
403
|
+
const runnerFailed = providerRejected
|
|
404
|
+
? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
|
|
405
|
+
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined;
|
|
406
|
+
proc.sandbox = {
|
|
407
|
+
mode: facts.mode,
|
|
408
|
+
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
|
409
|
+
enforcement: facts.enforcement,
|
|
410
|
+
...(runnerFailed ? { runnerFailed } : {}),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
super.onProcessDone(proc, stderr, providerRejected, providerError);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Execute one resolved spec inside the distro, confined by the per-call policy
|
|
418
|
+
* when there is one.
|
|
419
|
+
*
|
|
420
|
+
* Confinement wraps the DISTRO-side argv (`<shell> -lc <command>`) in `bwrap`
|
|
421
|
+
* and hands the result to `wsl.exe --exec`, so the sandbox is built inside the
|
|
422
|
+
* distro while the process that spawns it stays the ordinary Windows one. The
|
|
423
|
+
* `danger-full-access` escalation — and an operator's `sandbox: false` — skip
|
|
424
|
+
* the wrap entirely and run the command as before.
|
|
425
|
+
*
|
|
426
|
+
* @param spec - resolved execution settings.
|
|
427
|
+
* @returns the live shell process handle.
|
|
428
|
+
*/
|
|
429
|
+
async execute(spec) {
|
|
430
|
+
const distro = await this.distro();
|
|
431
|
+
const shell = await this.shell();
|
|
432
|
+
const resolved = await this.withDefaultWorkdir(spec);
|
|
433
|
+
const policy = resolved.sandboxPolicy;
|
|
434
|
+
if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
|
|
435
|
+
return this.executeArgv(resolved, () => this.argv(resolved, distro, shell));
|
|
436
|
+
}
|
|
437
|
+
const mode = policy.mode;
|
|
438
|
+
let confined;
|
|
439
|
+
const execution = await this.executeArgv(
|
|
440
|
+
resolved,
|
|
441
|
+
async (signal) => {
|
|
442
|
+
// Preparation is async, so it runs under executeArgv's deadline and
|
|
443
|
+
// cancellation handling rather than before them. A distro without a
|
|
444
|
+
// usable runner throws here, before anything is spawned.
|
|
445
|
+
confined = await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode }, { distro, signal });
|
|
446
|
+
signal.throwIfAborted();
|
|
447
|
+
return this.argv(resolved, distro, shell, confined.argv);
|
|
448
|
+
},
|
|
449
|
+
(process) => {
|
|
450
|
+
this.processFacts.set(process, {
|
|
451
|
+
mode,
|
|
452
|
+
enforcement: confined.enforcement,
|
|
453
|
+
denialSignatures: confined.denialSignatures,
|
|
454
|
+
runnerFailureRules: confined.runnerFailureRules,
|
|
455
|
+
runnerProgram: confined.argv[0],
|
|
456
|
+
workdir: resolved.workdir,
|
|
457
|
+
});
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
return WslShellExecutor.decorateResult(execution, (result) => {
|
|
461
|
+
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
|
|
462
|
+
const { enforcement, denialSignatures, runnerFailureRules } = confined;
|
|
463
|
+
// Runner failure outranks denial because the command did not run at all.
|
|
464
|
+
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
|
|
465
|
+
if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
|
|
466
|
+
return {
|
|
467
|
+
...result,
|
|
468
|
+
sandbox: {
|
|
469
|
+
mode,
|
|
470
|
+
denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
|
|
471
|
+
enforcement,
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}, (error) => {
|
|
475
|
+
// An upstream abort remains cancellation even when it prevents spawn.
|
|
476
|
+
if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
|
|
477
|
+
if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], resolved.workdir)) {
|
|
478
|
+
throw new SandboxUnavailableError(mode, String(error));
|
|
479
|
+
}
|
|
480
|
+
throw error;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Filesystem backend over a WSL distro's UNC share.
|
|
487
|
+
*
|
|
488
|
+
* Reads, listings, stats and searches reuse the host backend verbatim: the
|
|
489
|
+
* share is a real filesystem to Node, and the packaged ripgrep walks it too.
|
|
490
|
+
* Two things are deliberately replaced.
|
|
491
|
+
*
|
|
492
|
+
* **Resolution** accepts Linux paths (`/home/andy/proj/src/a.ts`) and shows them
|
|
493
|
+
* back as Linux paths, while `targetKey` stays the UNC path the host I/O uses —
|
|
494
|
+
* the seam treats `targetKey` as opaque and reserves `displayPath` for model and
|
|
495
|
+
* UI output, so this rewrite is contract-legal.
|
|
496
|
+
*
|
|
497
|
+
* **Mutation guards are hoisted.** The host backend publishes a guarded
|
|
498
|
+
* *creation* by hard-linking a staged file into place, and the WSL UNC share
|
|
499
|
+
* rejects hard links with `ENOTSUP`; it also refuses `symlink` with `EPERM`.
|
|
500
|
+
* Guarded creation through the share would therefore fail with `FS_IO_ERROR` on
|
|
501
|
+
* the most common write there is — creating a new file. This backend checks the
|
|
502
|
+
* caller's guard itself and then delegates the unguarded publication, which the
|
|
503
|
+
* share does support. The residual window is the same check-then-act race
|
|
504
|
+
* `dsh-fs-sandbox` already documents and accepts.
|
|
505
|
+
*
|
|
506
|
+
* **Publication is replaced.** Unguarded is not enough on its own: the host
|
|
507
|
+
* backend's Windows branch also copies the replaced file's security descriptor
|
|
508
|
+
* and publishes with `ReplaceFileW`, and neither exists on a 9p share — the
|
|
509
|
+
* descriptor read fails every write to an existing file outright. See the
|
|
510
|
+
* constructor for what runs instead.
|
|
511
|
+
*/
|
|
512
|
+
export class WslFileSystem extends LocalFileSystem {
|
|
513
|
+
static inject = ["sandboxPolicy"];
|
|
514
|
+
|
|
515
|
+
static Config = z.object({
|
|
516
|
+
/** Distro name; empty resolves WSL's default on first use. */
|
|
517
|
+
distro: z.string().default(""),
|
|
518
|
+
/**
|
|
519
|
+
* Default workdir for relative input; accepts a Linux path, a UNC path, or
|
|
520
|
+
* `C:\...`. Empty — the default — means the distro user's home, resolved
|
|
521
|
+
* inside the distro.
|
|
522
|
+
*/
|
|
523
|
+
cwd: z.string().default(""),
|
|
524
|
+
/** Path to `wsl.exe`, used only to resolve the default distro. */
|
|
525
|
+
wslPath: z.string().default("wsl.exe"),
|
|
526
|
+
/** UTF-8 byte limit per overwrite-diff side, mirroring the host backend. */
|
|
527
|
+
diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
|
|
528
|
+
/**
|
|
529
|
+
* Fence mutations by `ctx.sandboxPolicy`, and report the mode through
|
|
530
|
+
* {@link sandboxMode}. `false` restores the pre-sandbox behaviour exactly —
|
|
531
|
+
* an unfenced backend that reports `undefined` — for an operator who wants
|
|
532
|
+
* the old posture and accepts what it means: the model is told these
|
|
533
|
+
* operations are unconfined.
|
|
534
|
+
*/
|
|
535
|
+
sandbox: z.boolean().default(true),
|
|
536
|
+
/**
|
|
537
|
+
* Refuse any path that leaves the pinned distro. A WSL filesystem is a
|
|
538
|
+
* whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
|
|
539
|
+
* this the backend would silently widen the session far beyond the
|
|
540
|
+
* workspace the operator selected.
|
|
541
|
+
*/
|
|
542
|
+
restrictToDistro: z.boolean().default(true),
|
|
543
|
+
/**
|
|
544
|
+
* When a read-side operation reports `FS_NOT_FOUND` on a Linux path, ask the
|
|
545
|
+
* distro for that path's canonical form and retry once. This is what makes
|
|
546
|
+
* symlinked paths usable at all: the UNC share cannot traverse Linux
|
|
547
|
+
* symlinks, so `/etc/os-release`, `/bin` and `/lib` would otherwise be
|
|
548
|
+
* unreachable from the model's read tools. Costs one `wsl.exe` call per
|
|
549
|
+
* distinct failing path, cached.
|
|
550
|
+
*/
|
|
551
|
+
resolveSymlinks: z.boolean().default(true),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
constructor(ctx, config) {
|
|
555
|
+
super(ctx, config);
|
|
556
|
+
this.resolvedDistro = config.distro;
|
|
557
|
+
/** Cached distro-user home, the base for relative input when `cwd` is empty. */
|
|
558
|
+
this.resolvedHome = undefined;
|
|
559
|
+
/** The deployment default mode — the capability fact the tool layer reads. */
|
|
560
|
+
this.defaultMode = ctx.sandboxPolicy.defaultMode;
|
|
561
|
+
/** targetKey -> canonical replacement target, for symlinked paths. */
|
|
562
|
+
this.canonicalTargets = new Map();
|
|
563
|
+
// `dsh-fs-local` takes a Windows branch whenever the destination already
|
|
564
|
+
// exists — which is how it knows to preserve the replaced file's security
|
|
565
|
+
// descriptor, and what makes every write to an existing file fail here:
|
|
566
|
+
//
|
|
567
|
+
// * it reads that descriptor with `GetFileSecurityW` and applies it to the
|
|
568
|
+
// staged temp. A 9p share carries no Windows security descriptor, so the
|
|
569
|
+
// call fails with `EIO (Win32 1)` and takes the whole write with it —
|
|
570
|
+
// `edit` and every overwrite, not just an exotic path.
|
|
571
|
+
// * it publishes with `ReplaceFileW`, which is not a 9p primitive.
|
|
572
|
+
//
|
|
573
|
+
// Both are replaced with their POSIX equivalents: the staged temp gets the
|
|
574
|
+
// target's real mode from inside the distro (see `copyModeInDistro` — the
|
|
575
|
+
// share ignores a host-side chmod), and publication is the plain atomic
|
|
576
|
+
// rename the share does support.
|
|
577
|
+
//
|
|
578
|
+
// Trade-off worth knowing: those two hooks are the only DACL handling in the
|
|
579
|
+
// inherited backend, so a *Windows* path served by this provider loses its
|
|
580
|
+
// descriptor preservation. This provider exists to serve distro paths, where
|
|
581
|
+
// no DACL can exist in the first place.
|
|
582
|
+
this.internals = {
|
|
583
|
+
...this.internals,
|
|
584
|
+
copyFileDacl: async () => {},
|
|
585
|
+
replaceFile: (replaced, replacement) => this.publish(replaced, replacement),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The mode this backend fences mutations with, or `undefined` when the
|
|
591
|
+
* operator turned the fence off. This is the capability fact `dsh-tool-fs`
|
|
592
|
+
* reads to advertise the escalation fields honestly, so it must disappear
|
|
593
|
+
* together with the enforcement — never claim a boundary that is not applied.
|
|
594
|
+
* @returns the deployment default mode, or `undefined` when unfenced.
|
|
595
|
+
*/
|
|
596
|
+
get sandboxMode() {
|
|
597
|
+
return this.config.sandbox ? this.defaultMode : undefined;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Publish a staged temp file over its target the way a 9p share supports:
|
|
602
|
+
* give the temp the target's POSIX mode inside the distro, then rename it into
|
|
603
|
+
* place.
|
|
604
|
+
*
|
|
605
|
+
* The mode is applied before the rename on purpose. The share drops a mode set
|
|
606
|
+
* from the Windows side, but a rename preserves the inode's mode — so setting
|
|
607
|
+
* it on the staged temp is what makes the published file keep the bits it had,
|
|
608
|
+
* without a second in-distro call after publication and without the version
|
|
609
|
+
* the write returns going stale behind a late `chmod`.
|
|
610
|
+
*
|
|
611
|
+
* @param replaced - the existing file being replaced (a UNC path).
|
|
612
|
+
* @param replacement - the staged temp file holding the new content.
|
|
613
|
+
*/
|
|
614
|
+
async publish(replaced, replacement) {
|
|
615
|
+
const source = uncToPosix(replaced);
|
|
616
|
+
const staged = uncToPosix(replacement);
|
|
617
|
+
if (source !== undefined && staged !== undefined) {
|
|
618
|
+
await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
|
|
619
|
+
}
|
|
620
|
+
await rename(replacement, replaced);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The pinned distro, resolving WSL's default on first use.
|
|
625
|
+
* @returns the distro name.
|
|
626
|
+
*/
|
|
627
|
+
async distro() {
|
|
628
|
+
if (this.resolvedDistro.length === 0) this.resolvedDistro = await defaultDistro({ wslPath: this.config.wslPath });
|
|
629
|
+
return this.resolvedDistro;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Translate one caller path into the world path, enforcing the distro fence.
|
|
634
|
+
* @param path - a Linux, UNC or Windows path.
|
|
635
|
+
* @param cwd - the caller's override of the configured default workdir.
|
|
636
|
+
* @returns the host path to operate on.
|
|
637
|
+
*/
|
|
638
|
+
async worldPath(path, cwd) {
|
|
639
|
+
const distro = await this.distro();
|
|
640
|
+
let base = cwd ?? this.config.cwd;
|
|
641
|
+
if (String(base ?? "").trim().length === 0 && isRelativeWorldPath(path)) {
|
|
642
|
+
// An empty `cwd` means the distro user's home. Only relative input
|
|
643
|
+
// consults the base at all, which is also the only case that has to pay
|
|
644
|
+
// for the `wsl.exe` query — an absolute path never does.
|
|
645
|
+
base = await distroHome(this);
|
|
646
|
+
}
|
|
647
|
+
const world = toWorldPath(path, { distro, cwd: base });
|
|
648
|
+
if (this.config.restrictToDistro && isWslUnc(world) && !isUnderDistro(world, distro)) {
|
|
649
|
+
throw new FsError(`"${path}" is outside distro "${distro}"`, "FS_SANDBOX_DENIED");
|
|
650
|
+
}
|
|
651
|
+
return world;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Resolve a path to a target whose `targetKey` is the UNC identity the host
|
|
656
|
+
* I/O stack uses and whose `displayPath` is the Linux path the model sees.
|
|
657
|
+
* @param path - a Linux, UNC or Windows path.
|
|
658
|
+
* @param opts - optional `cwd` override and `signal`.
|
|
659
|
+
* @returns the resolved `FsTarget`.
|
|
660
|
+
*/
|
|
661
|
+
async resolve(path, opts) {
|
|
662
|
+
const distro = await this.distro();
|
|
663
|
+
const target = await super.resolve(await this.worldPath(path, opts?.cwd), opts);
|
|
664
|
+
return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Enforce the per-call file-effect policy against one target and return the
|
|
669
|
+
* EXACT target the mutation must use, so the checked identity is the mutated
|
|
670
|
+
* one (no check-here-write-there window).
|
|
671
|
+
*
|
|
672
|
+
* `read-only` denies outright. `workspace-write` re-resolves the target NOW
|
|
673
|
+
* (reflecting a symlink ancestor swapped since the tool resolved it), requires
|
|
674
|
+
* containment under a writable root — the workspace plus the temp area of the
|
|
675
|
+
* world the workspace lives in — and hands back that fresh target.
|
|
676
|
+
* `danger-full-access` is the approved escalation and is not fenced. Refusal
|
|
677
|
+
* is the structured `FS_SANDBOX_DENIED` the tool layer maps to its
|
|
678
|
+
* model-facing `[sandbox: …]` marker and escalation hint.
|
|
679
|
+
*
|
|
680
|
+
* @param target - the resolved target to mutate.
|
|
681
|
+
* @param sandboxPolicy - the per-call mode and workspace root; omit for the
|
|
682
|
+
* deployment policy.
|
|
683
|
+
* @returns the target the mutation must use.
|
|
684
|
+
* @throws FsError with `FS_SANDBOX_DENIED` when the policy refuses it.
|
|
685
|
+
*/
|
|
686
|
+
async checkedTarget(target, sandboxPolicy) {
|
|
687
|
+
if (!this.config.sandbox) return target;
|
|
688
|
+
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
689
|
+
const { mode } = policy;
|
|
690
|
+
if (mode === "danger-full-access") return target;
|
|
691
|
+
if (mode === "read-only") {
|
|
692
|
+
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
|
|
693
|
+
}
|
|
694
|
+
const fresh = await this.resolve(target.displayPath);
|
|
695
|
+
const distro = await this.distro();
|
|
696
|
+
for (const root of wslWritableRoots(policy, distro)) {
|
|
697
|
+
if (isWorldPathUnder(fresh.targetKey, root, { distro })) return fresh;
|
|
698
|
+
}
|
|
699
|
+
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The canonical replacement target for a Linux symlink this share cannot
|
|
704
|
+
* traverse, or undefined when the path is not one.
|
|
705
|
+
* @param target - the resolved target that failed a read-side operation.
|
|
706
|
+
* @param signal - optional cancellation.
|
|
707
|
+
* @returns the replacement target, or undefined.
|
|
708
|
+
*/
|
|
709
|
+
async canonicalTarget(target, signal) {
|
|
710
|
+
if (!this.config.resolveSymlinks) return undefined;
|
|
711
|
+
const cached = this.canonicalTargets.get(target.targetKey);
|
|
712
|
+
if (cached !== undefined) return cached;
|
|
713
|
+
const parsed = uncToPosix(target.targetKey);
|
|
714
|
+
if (parsed === undefined) return undefined;
|
|
715
|
+
const canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
|
|
716
|
+
if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
|
|
717
|
+
const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
|
|
718
|
+
this.canonicalTargets.set(target.targetKey, replacement);
|
|
719
|
+
return replacement;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Run one read-side operation, retrying once through the distro's canonical
|
|
724
|
+
* path when the share cannot follow a symlink. `displayPath` is preserved, so
|
|
725
|
+
* the model still sees the path it asked for while `targetKey` names the file
|
|
726
|
+
* the host can actually open.
|
|
727
|
+
*
|
|
728
|
+
* @param target - the resolved target.
|
|
729
|
+
* @param signal - optional cancellation.
|
|
730
|
+
* @param operation - the inherited operation to attempt.
|
|
731
|
+
* @returns the operation's result.
|
|
732
|
+
*/
|
|
733
|
+
async withCanonicalRetry(target, signal, operation) {
|
|
734
|
+
let value;
|
|
735
|
+
try {
|
|
736
|
+
value = await operation(target);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
if (!(error instanceof FsError) || error.code !== "FS_NOT_FOUND") throw error;
|
|
739
|
+
const canonical = await this.canonicalTarget(target, signal);
|
|
740
|
+
if (canonical === undefined) throw error;
|
|
741
|
+
return operation(canonical);
|
|
742
|
+
}
|
|
743
|
+
// `stat` reports an absent file by RETURNING undefined rather than throwing,
|
|
744
|
+
// and an untraversable symlink takes exactly that path over this share. The
|
|
745
|
+
// model-facing read tool branches on precisely that undefined — it resolves,
|
|
746
|
+
// stats, and renders "not found" itself — so retrying only the throw is not
|
|
747
|
+
// enough: the silent miss has to be retried here too.
|
|
748
|
+
if (value === undefined) {
|
|
749
|
+
const canonical = await this.canonicalTarget(target, signal);
|
|
750
|
+
if (canonical !== undefined) return operation(canonical);
|
|
751
|
+
}
|
|
752
|
+
return value;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async stat(target, signal) {
|
|
756
|
+
return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async readText(target, signal) {
|
|
760
|
+
return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async streamText(target, signal) {
|
|
764
|
+
return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async readBytes(target, signal, maxBytes) {
|
|
768
|
+
return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async readByteRange(target, range, signal) {
|
|
772
|
+
return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* List one level, rewriting each child's display path into the distro's
|
|
777
|
+
* coordinate system — the host backend reports children with UNC display paths,
|
|
778
|
+
* which would leak `\\wsl.localhost\...` into model-facing output.
|
|
779
|
+
*
|
|
780
|
+
* @param target - the resolved directory.
|
|
781
|
+
* @param signal - optional cancellation.
|
|
782
|
+
* @returns the directory rows.
|
|
783
|
+
*/
|
|
784
|
+
async listDir(target, signal) {
|
|
785
|
+
const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
|
|
786
|
+
const distro = await this.distro();
|
|
787
|
+
return entries.map((entry) => ({
|
|
788
|
+
...entry,
|
|
789
|
+
target: { targetKey: entry.target.targetKey, displayPath: toDisplayPath(entry.target.targetKey, distro) },
|
|
790
|
+
}));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Map an absolute host path into this execution world. POSIX input is a Linux
|
|
795
|
+
* path inside the distro; anything else keeps the host backend's behaviour.
|
|
796
|
+
* @param hostPath - an absolute path.
|
|
797
|
+
* @returns the process path for the same file, or undefined when unmappable.
|
|
798
|
+
*/
|
|
799
|
+
async processPathFromHostPath(hostPath) {
|
|
800
|
+
return this.worldPath(hostPath);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Watching a 9p share is neither reliable nor cheap, so this provider refuses
|
|
805
|
+
* rather than arming a watcher that may never fire — the seam's documented
|
|
806
|
+
* failure mode for a provider that cannot watch.
|
|
807
|
+
* @returns a rejection carrying `FS_IO_ERROR`.
|
|
808
|
+
*/
|
|
809
|
+
watch() {
|
|
810
|
+
return Promise.reject(new FsError("Filesystem watching is not supported over the WSL UNC share.", "FS_IO_ERROR"));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Check the caller's mutation guard immediately before publication.
|
|
815
|
+
* @param target - the resolved target.
|
|
816
|
+
* @param expected - the write intent, or undefined for an unconditional write.
|
|
817
|
+
* @param signal - optional cancellation.
|
|
818
|
+
* @throws FsError with the same codes the host backend raises.
|
|
819
|
+
*/
|
|
820
|
+
async assertGuard(target, expected, signal) {
|
|
821
|
+
if (expected === undefined) return;
|
|
822
|
+
const existing = await this.stat(target, signal);
|
|
823
|
+
if (expected.kind === "replaceIfVersion") {
|
|
824
|
+
if (existing === undefined) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
825
|
+
if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (expected.kind === "createIfAbsent" && existing !== undefined) {
|
|
829
|
+
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Fence the write by the per-call policy, then keep the guard's observable
|
|
835
|
+
* behaviour.
|
|
836
|
+
* @param target - the resolved target.
|
|
837
|
+
* @param content - the full new file content.
|
|
838
|
+
* @param expected - the write intent.
|
|
839
|
+
* @param signal - optional cancellation.
|
|
840
|
+
* @param sandboxPolicy - the per-call mode and workspace root; omit for the
|
|
841
|
+
* deployment policy.
|
|
842
|
+
* @returns the write outcome.
|
|
843
|
+
*/
|
|
844
|
+
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
845
|
+
// The fence runs first and its fresh target is the one checked and written,
|
|
846
|
+
// so a refusal costs no I/O and cannot be raced by a swapped symlink.
|
|
847
|
+
const fenced = await this.checkedTarget(target, sandboxPolicy);
|
|
848
|
+
await this.assertGuard(fenced, expected, signal);
|
|
849
|
+
// The guard is deliberately not forwarded: the host backend would publish a
|
|
850
|
+
// guarded creation with a hard link, which this filesystem rejects. Once
|
|
851
|
+
// past the guard, publication is the overridden `publish` (see the
|
|
852
|
+
// constructor), not the inherited Windows one.
|
|
853
|
+
return super.writeText(fenced, content, undefined, signal);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Fence the edit by the same policy, keeping the guard's observable behaviour.
|
|
858
|
+
* @param target - the resolved target.
|
|
859
|
+
* @param edit - the literal search/replace request.
|
|
860
|
+
* @param expected - the version guard.
|
|
861
|
+
* @param signal - optional cancellation.
|
|
862
|
+
* @param sandboxPolicy - the per-call mode and workspace root; omit for the
|
|
863
|
+
* deployment policy.
|
|
864
|
+
* @returns the edit outcome.
|
|
865
|
+
*/
|
|
866
|
+
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
867
|
+
const fenced = await this.checkedTarget(target, sandboxPolicy);
|
|
868
|
+
await this.assertGuard(fenced, expected, signal);
|
|
869
|
+
return super.editText(fenced, edit, undefined, signal);
|
|
870
|
+
}
|
|
871
|
+
}
|