dsh-plugin-wsl-env 0.7.5 → 0.9.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +345 -8
  2. package/CONTRIBUTING.md +221 -68
  3. package/README.md +50 -35
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +23 -7
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +200 -57
  8. package/docs/CONFIGURATION.md +180 -16
  9. package/docs/LIMITATIONS.md +91 -33
  10. package/docs/PARITY.md +66 -38
  11. package/docs/PEER-PARITY.md +95 -32
  12. package/docs/README.zh.md +49 -31
  13. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  14. package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
  15. package/lib/agent-confined.d.ts +42 -0
  16. package/lib/agent-confined.js +25 -41
  17. package/lib/agent-errors.d.ts +31 -0
  18. package/lib/agent-errors.js +19 -16
  19. package/lib/agent-exec.d.ts +159 -0
  20. package/lib/agent-exec.js +151 -165
  21. package/lib/agent-protocol.d.ts +192 -0
  22. package/lib/agent-protocol.js +76 -100
  23. package/lib/agent-shared.d.ts +27 -0
  24. package/lib/agent-shared.js +12 -12
  25. package/lib/agent.d.ts +353 -0
  26. package/lib/agent.js +536 -552
  27. package/lib/auto-preset.d.ts +110 -0
  28. package/lib/auto-preset.js +157 -141
  29. package/lib/bwrap.d.ts +90 -0
  30. package/lib/bwrap.js +18 -18
  31. package/lib/file-reference-wsl.d.ts +160 -0
  32. package/lib/file-reference-wsl.js +192 -173
  33. package/lib/fs-decisions.d.ts +107 -0
  34. package/lib/fs-decisions.js +64 -60
  35. package/lib/fs-routing.d.ts +182 -0
  36. package/lib/fs-routing.js +173 -116
  37. package/lib/fs-substrate.d.ts +258 -0
  38. package/lib/fs-substrate.js +248 -248
  39. package/lib/fs.d.ts +10 -0
  40. package/lib/fs.js +5 -0
  41. package/lib/fsio-agent.d.ts +307 -0
  42. package/lib/fsio-agent.js +414 -410
  43. package/lib/fsio-text.d.ts +115 -0
  44. package/lib/fsio-text.js +104 -84
  45. package/lib/git-route.d.ts +100 -0
  46. package/lib/git-route.js +46 -47
  47. package/lib/index.d.ts +1003 -0
  48. package/lib/index.js +1106 -1003
  49. package/lib/listing.d.ts +114 -0
  50. package/lib/listing.js +68 -55
  51. package/lib/paths.d.ts +182 -0
  52. package/lib/paths.js +134 -109
  53. package/lib/picker.d.ts +266 -0
  54. package/lib/picker.js +371 -351
  55. package/lib/ports.d.ts +44 -0
  56. package/lib/ports.js +39 -29
  57. package/lib/preflight.d.ts +91 -0
  58. package/lib/preflight.js +94 -0
  59. package/lib/preset-choice.d.ts +33 -0
  60. package/lib/preset-choice.js +15 -7
  61. package/lib/probe-cache.d.ts +51 -0
  62. package/lib/probe-cache.js +43 -29
  63. package/lib/sandbox-core.d.ts +149 -0
  64. package/lib/sandbox-core.js +239 -87
  65. package/lib/sandbox.d.ts +48 -0
  66. package/lib/sandbox.js +4 -4
  67. package/lib/search-exec.d.ts +96 -0
  68. package/lib/search-exec.js +52 -79
  69. package/lib/search-route.d.ts +75 -0
  70. package/lib/search-route.js +30 -31
  71. package/lib/shell-env.d.ts +88 -0
  72. package/lib/shell-env.js +78 -74
  73. package/lib/shell.d.ts +10 -0
  74. package/lib/shell.js +5 -0
  75. package/lib/subprocess.d.ts +313 -0
  76. package/lib/subprocess.js +267 -262
  77. package/lib/terminal-activity.d.ts +98 -0
  78. package/lib/terminal-activity.js +29 -39
  79. package/lib/terminal-route.d.ts +27 -0
  80. package/lib/terminal-route.js +16 -8
  81. package/lib/watcher.d.ts +77 -0
  82. package/lib/watcher.js +120 -126
  83. package/lib/workspace-files-route.d.ts +132 -0
  84. package/lib/workspace-files-route.js +73 -81
  85. package/lib/workspace-files-wsl.d.ts +257 -0
  86. package/lib/workspace-files-wsl.js +311 -251
  87. package/lib/wsl.d.ts +266 -0
  88. package/lib/wsl.js +150 -155
  89. package/locale/preflight/en.json +6 -0
  90. package/locale/preflight/zh.json +6 -0
  91. package/package.json +78 -22
  92. package/scripts/diagnose.sh +92 -0
package/lib/index.js CHANGED
@@ -33,20 +33,22 @@
33
33
  * confined. WSL access is inherently outside the harness's file sandbox; do not
34
34
  * paper over that by reporting a mode this provider does not enforce.
35
35
  *
36
+ * This is a TypeScript source built to `lib/index.js`; edit THIS file and run
37
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
38
+ * fails when it drifts.
39
+ *
36
40
  * @module dsh-plugin-wsl
37
41
  */
38
-
39
- import { fileURLToPath } from "node:url";
40
42
  import z from "@deepseek-ai/schemastery";
41
43
  import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
42
44
  import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
43
45
  import { FsError } from "@deepseek-ai/dsh-fs";
44
46
  import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
45
- import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
47
+ import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, posixToUnc, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
46
48
  import { isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, shareSubstrateRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
47
49
  import { WslSandbox } from "./sandbox.js";
50
+ import { bwrapFailure, missingBwrapRemedy, withBwrapHint } from "./sandbox-core.js";
48
51
  import { armDistroWatcher } from "./watcher.js";
49
- import { WslAgent } from "./agent.js";
50
52
  import { AgentUnavailableError } from "./agent-errors.js";
51
53
  import { agentExecutionHandle } from "./agent-exec.js";
52
54
  import { sharedAgent } from "./agent-shared.js";
@@ -56,7 +58,6 @@ import { confinedAgent } from "./agent-confined.js";
56
58
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
57
59
  // can use them without pulling this file's executor and filesystem peers in.
58
60
  import { defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
59
-
60
61
  /**
61
62
  * Every model shell call gets a rebuilt managed `DSH_*` namespace (see
62
63
  * `dsh-shell-env` / `ctx.shellEnv`). Those names exist only in the Windows-side
@@ -71,11 +72,8 @@ import { defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure,
71
72
  */
72
73
  const DSH_ENV_PREFIX = "DSH_";
73
74
  const PATH_TRANSLATED = new Set([`${DSH_ENV_PREFIX}HOME`, `${DSH_ENV_PREFIX}PROFILE_DIR`]);
74
-
75
-
76
75
  /** Env names that are meaningful on the Linux side and safe to forward. */
77
76
  const SAFE_FORWARD = ["NO_COLOR", "TERM", "PAGER", "GIT_PAGER", "LANG", "LC_ALL"];
78
-
79
77
  /**
80
78
  * The configured default workdir, or an empty string when the operator left it
81
79
  * unset. `cwd` is a volatile field on both providers, so it is read through
@@ -85,9 +83,8 @@ const SAFE_FORWARD = ["NO_COLOR", "TERM", "PAGER", "GIT_PAGER", "LANG", "LC_ALL"
85
83
  * @returns the configured directory, trimmed, or `""`.
86
84
  */
87
85
  function configuredCwd(config) {
88
- return String(config.cwd?.get?.() ?? "").trim();
86
+ return String(config.cwd?.get?.() ?? "").trim();
89
87
  }
90
-
91
88
  /**
92
89
  * The distro user's home as a Linux path, cached on the provider instance whose
93
90
  * `distro`/`wslPath` config it belongs to.
@@ -101,13 +98,15 @@ function configuredCwd(config) {
101
98
  * @returns an absolute Linux path.
102
99
  */
103
100
  async function distroHome(owner) {
104
- if (owner.resolvedHome === undefined) {
105
- const distro = await owner.distro();
106
- owner.resolvedHome = await linuxHomePath(distro, { wslPath: owner.config.wslPath });
107
- }
108
- return owner.resolvedHome;
101
+ if (owner.resolvedHome === undefined) {
102
+ // Both providers carry `wslPath` in their own config; the base classes'
103
+ // `config` types do not know the key, so the shape is asserted here.
104
+ const config = owner.config;
105
+ const distro = await owner.distro();
106
+ owner.resolvedHome = await linuxHomePath(distro, { wslPath: config.wslPath });
107
+ }
108
+ return owner.resolvedHome;
109
109
  }
110
-
111
110
  /**
112
111
  * The roots a `workspace-write` policy may write under, in the vocabulary this
113
112
  * backend's targets are spelled in.
@@ -125,16 +124,19 @@ async function distroHome(owner) {
125
124
  * @returns canonical writable roots; empty exactly under `read-only`.
126
125
  */
127
126
  function wslWritableRoots(policy, distro) {
128
- if (policy.mode !== "workspace-write") return [];
129
- const roots = new Set([canonicalPath(policy.workspaceRoot)]);
130
- if (isWslUnc(policy.workspaceRoot)) {
131
- roots.add(canonicalPath(posixToUnc(distro, "/tmp")));
132
- } else {
133
- for (const root of writableRoots(policy)) roots.add(root);
134
- }
135
- return [...roots];
127
+ if (policy.mode !== "workspace-write")
128
+ return [];
129
+ const roots = new Set([canonicalPath(policy.workspaceRoot)]);
130
+ if (isWslUnc(policy.workspaceRoot)) {
131
+ roots.add(canonicalPath(posixToUnc(distro, "/tmp")));
132
+ }
133
+ else {
134
+ for (const root of writableRoots(policy))
135
+ roots.add(root);
136
+ }
137
+ return [...roots];
136
138
  }
137
-
139
+ const inheritedSpawnSpec = LocalBashExecutor.prototype.spawnSpec;
138
140
  /**
139
141
  * Bash executor that runs every command inside a WSL distro.
140
142
  *
@@ -144,1034 +146,1135 @@ function wslWritableRoots(policy, distro) {
144
146
  * `wsl.exe`) and the process working directory (wsl.exe must be started from a
145
147
  * Windows directory, while the Linux directory rides on `--cd`).
146
148
  */
149
+ // @ts-expect-error — LocalBashExecutor.spawnSpec is private upstream; a wsl.exe spawn must start from a Windows directory (docs/UPSTREAM-SPAWN-SEAM.md)
147
150
  export class WslShellExecutor extends LocalBashExecutor {
148
- static inject = ["subprocess", "sandboxPolicy"];
149
-
150
- static Config = z.object({
151
- /** Distro name; empty means WSL's own default distro. */
152
- distro: z.string().default(""),
153
- /** Path to `wsl.exe`; overridable for a non-standard install. */
154
- wslPath: z.string().default("wsl.exe"),
151
+ static inject = ["subprocess", "sandboxPolicy"];
152
+ static Config = z.object({
153
+ /** Distro name; empty means WSL's own default distro. */
154
+ distro: z.string().default(""),
155
+ /** Path to `wsl.exe`; overridable for a non-standard install. */
156
+ wslPath: z.string().default("wsl.exe"),
157
+ /**
158
+ * The shell commands run in. Empty means "resolve the login shell of the
159
+ * distro's user" — hardcoding bash silently ignores a user whose passwd
160
+ * entry points at zsh, fish, dash, or anything else, losing their PATH and
161
+ * rc setup. Set an absolute path inside the distro to pin one.
162
+ */
163
+ shell: z.string().default(""),
164
+ /**
165
+ * Login-shell semantics (`<shell> -lc`) source the profile scripts, which is
166
+ * what makes an interactive WSL setup's PATH (nvm, cargo, pyenv) visible.
167
+ * Set false for a fast, fully deterministic `<shell> -c`. Applies only to the
168
+ * POSIX family — see `shellArgs` for why other shells get a bare `-c`.
169
+ */
170
+ loginShell: z.boolean().default(true),
171
+ /**
172
+ * Confine every command inside the distro with `bwrap`, and report the
173
+ * policy's mode through {@link sandboxMode}. Fails closed: a distro without
174
+ * a usable `bwrap` produces `SANDBOX_UNAVAILABLE` rather than an unconfined
175
+ * run. `false` restores the pre-sandbox behaviour exactly — commands run
176
+ * unconfined and `sandboxMode` is `undefined`, so the model is told so.
177
+ */
178
+ sandbox: z.boolean().default(true),
179
+ /**
180
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
181
+ * Windows drive from confined commands: its files cannot be read or
182
+ * exfiltrated and its executables cannot be launched through interop —
183
+ * the hole that lets a "sandboxed" command reach your full Windows token.
184
+ * A narrowing, not a closure (a command can still write an executable
185
+ * into the workspace and run it — binfmt dispatches on content, not
186
+ * location), so enforcement stays `partial` and the sandbox fact reports
187
+ * `windowsDrive: "masked"`. Costs: confined commands cannot read or touch
188
+ * anything under `/mnt`, and tools that probe the drive degrade. The
189
+ * complete closure is distro-level: `[interop] enabled=false` in
190
+ * `wsl.conf`.
191
+ */
192
+ maskWindowsDrive: z.boolean().default(false),
193
+ /**
194
+ * Default workdir for a request that names none; accepts `/home/andy/proj`,
195
+ * a UNC path, or `C:\...`. Empty — the default — means the distro user's
196
+ * home, resolved inside the distro.
197
+ */
198
+ cwd: z.string().volatile(),
199
+ /** Windows directory the `wsl.exe` process itself starts in. */
200
+ hostCwd: z.string().volatile(),
201
+ /** Extra env names to forward into the distro through WSLENV. */
202
+ forwardEnv: z.array(z.string()).default([]),
203
+ timeoutMs: z.number().default(12e4).volatile(),
204
+ maxTimeoutMs: z.number().default(6e5).volatile(),
205
+ maxOutputBytes: z.number().default(64e3).volatile(),
206
+ maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
207
+ graceMs: z.number().default(3e3).volatile(),
208
+ /**
209
+ * Run commands through the resident agent when it is up (one long-lived
210
+ * in-distro process; in-distro timeout semantics), falling back to the
211
+ * one-shot `wsl.exe` path whenever the agent is out. `false` never uses
212
+ * the agent. The confinement, the output budgets' shapes and the result
213
+ * decoration are identical on both paths.
214
+ */
215
+ agent: z.boolean().default(true),
216
+ });
155
217
  /**
156
- * The shell commands run in. Empty means "resolve the login shell of the
157
- * distro's user" — hardcoding bash silently ignores a user whose passwd
158
- * entry points at zsh, fish, dash, or anything else, losing their PATH and
159
- * rc setup. Set an absolute path inside the distro to pin one.
218
+ * @param ctx - the composition context.
219
+ * @param config - the validated plugin config.
160
220
  */
161
- shell: z.string().default(""),
221
+ constructor(ctx, config) {
222
+ super(ctx, config);
223
+ this.resolvedDistro = config.distro;
224
+ this.resolvedShell = config.shell;
225
+ this.resolvedHome = undefined;
226
+ this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
227
+ this.sandbox = config.sandbox ? new WslSandbox({ wslPath: config.wslPath }) : undefined;
228
+ this.processFacts = new Map();
229
+ }
162
230
  /**
163
- * Login-shell semantics (`<shell> -lc`) source the profile scripts, which is
164
- * what makes an interactive WSL setup's PATH (nvm, cargo, pyenv) visible.
165
- * Set false for a fast, fully deterministic `<shell> -c`. Applies only to the
166
- * POSIX family — see `shellArgs` for why other shells get a bare `-c`.
231
+ * The mode this executor confines with, or `undefined` when it does not
232
+ * confine at all. `dsh-tool-bash` reads exactly this to decide whether to
233
+ * advertise the escalation field, so it must disappear together with the
234
+ * enforcement — never claim a boundary that is not applied.
235
+ *
236
+ * @returns the deployment default mode, or `undefined` when unconfined.
167
237
  */
168
- loginShell: z.boolean().default(true),
238
+ get sandboxMode() {
239
+ return this.mode;
240
+ }
169
241
  /**
170
- * Confine every command inside the distro with `bwrap`, and report the
171
- * policy's mode through {@link sandboxMode}. Fails closed: a distro without
172
- * a usable `bwrap` produces `SANDBOX_UNAVAILABLE` rather than an unconfined
173
- * run. `false` restores the pre-sandbox behaviour exactly — commands run
174
- * unconfined and `sandboxMode` is `undefined`, so the model is told so.
242
+ * The distro every command runs in, resolving WSL's default on first use.
243
+ *
244
+ * @returns the distro name.
175
245
  */
176
- sandbox: z.boolean().default(true),
246
+ async distro() {
247
+ const config = this.config;
248
+ if (this.resolvedDistro.length === 0)
249
+ this.resolvedDistro = await defaultDistro({ wslPath: config.wslPath });
250
+ return this.resolvedDistro;
251
+ }
177
252
  /**
178
- * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
179
- * Windows drive from confined commands: its files cannot be read or
180
- * exfiltrated and its executables cannot be launched through interop —
181
- * the hole that lets a "sandboxed" command reach your full Windows token.
182
- * A narrowing, not a closure (a command can still write an executable
183
- * into the workspace and run it — binfmt dispatches on content, not
184
- * location), so enforcement stays `partial` and the sandbox fact reports
185
- * `windowsDrive: "masked"`. Costs: confined commands cannot read or touch
186
- * anything under `/mnt`, and tools that probe the drive degrade. The
187
- * complete closure is distro-level: `[interop] enabled=false` in
188
- * `wsl.conf`.
253
+ * The shell every command runs in: the configured one, else the login shell of
254
+ * the distro user, resolved once and cached.
255
+ *
256
+ * @returns an absolute path to the shell inside the distro.
189
257
  */
190
- maskWindowsDrive: z.boolean().default(false),
258
+ async shellName() {
259
+ // Consumed by the shell tool after upstream change A: it names and describes
260
+ // itself from the executor, so a zsh distro is never presented as bash.
261
+ const shell = await this.shell();
262
+ const base = String(shell ?? "").split("/").pop() ?? "";
263
+ const name = base.replace(/\.exe$/i, "").replace(/^[-.]+/, "");
264
+ return /^[a-z0-9_+-]+$/i.test(name) ? name : "bash";
265
+ }
191
266
  /**
192
- * Default workdir for a request that names none; accepts `/home/andy/proj`,
193
- * a UNC path, or `C:\...`. Empty — the default — means the distro user's
194
- * home, resolved inside the distro.
267
+ * The shell every command runs in, resolving the distro login shell on
268
+ * first use.
269
+ *
270
+ * @returns an absolute path to the shell inside the distro.
195
271
  */
196
- cwd: z.string().volatile(),
197
- /** Windows directory the `wsl.exe` process itself starts in. */
198
- hostCwd: z.string().volatile(),
199
- /** Extra env names to forward into the distro through WSLENV. */
200
- forwardEnv: z.array(z.string()).default([]),
201
- timeoutMs: z.number().default(12e4).volatile(),
202
- maxTimeoutMs: z.number().default(6e5).volatile(),
203
- maxOutputBytes: z.number().default(64e3).volatile(),
204
- maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
205
- graceMs: z.number().default(3e3).volatile(),
272
+ async shell() {
273
+ const config = this.config;
274
+ if (this.resolvedShell.length === 0) {
275
+ const distro = await this.distro();
276
+ // An unresolvable shell is a retry, not a pin: a failed probe (a cold
277
+ // distro, a flaky first wsl.exe call) used to cache the "bash"
278
+ // placeholder for this executor's lifetime. The runtime fallback below
279
+ // keeps the command running; a later call resolves the real shell.
280
+ const resolved = await defaultShell(distro, { wslPath: config.wslPath });
281
+ if (resolved)
282
+ this.resolvedShell = resolved;
283
+ return resolved ?? "bash";
284
+ }
285
+ return this.resolvedShell;
286
+ }
206
287
  /**
207
- * Run commands through the resident agent when it is up (one long-lived
208
- * in-distro process; in-distro timeout semantics), falling back to the
209
- * one-shot `wsl.exe` path whenever the agent is out. `false` never uses
210
- * the agent. The confinement, the output budgets' shapes and the result
211
- * decoration are identical on both paths.
288
+ * Windows directory the `wsl.exe` process is started from.
289
+ *
290
+ * @returns the host directory to spawn in.
212
291
  */
213
- agent: z.boolean().default(true),
214
- });
215
-
216
- constructor(ctx, config) {
217
- super(ctx, config);
218
- /** Cached default distro, resolved once from `wsl.exe -l -q`. */
219
- this.resolvedDistro = config.distro;
220
- /** Cached distro-user login shell, resolved once. */
221
- this.resolvedShell = config.shell;
222
- /** Cached distro-user home, the workdir when `cwd` is empty. */
223
- this.resolvedHome = undefined;
224
- /** The default mode — the capability fact the tool layer reads. */
225
- this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
226
- /** The distro-side confinement, or nothing when the operator opted out. */
227
- this.sandbox = config.sandbox ? new WslSandbox({ wslPath: config.wslPath }) : undefined;
292
+ hostCwd() {
293
+ const config = this.config;
294
+ return config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
295
+ }
228
296
  /**
229
- * Per-process confinement facts retained until settlement. Overlapping calls
230
- * can carry different policies, so a shared latest-wrap value would classify
231
- * a process against the wrong facts. Unconfined processes have no entry.
297
+ * The shared resident agent serving this executor's distro.
298
+ *
299
+ * @param distro - the resolved distro name.
300
+ * @returns the shared agent.
232
301
  */
233
- this.processFacts = new Map();
234
- }
235
-
236
- /**
237
- * The mode this executor confines with, or `undefined` when it does not
238
- * confine at all. `dsh-tool-bash` reads exactly this to decide whether to
239
- * advertise the escalation field, so it must disappear together with the
240
- * enforcement — never claim a boundary that is not applied.
241
- * @returns the deployment default mode, or `undefined` when unconfined.
242
- */
243
- get sandboxMode() {
244
- return this.mode;
245
- }
246
-
247
- /**
248
- * The distro every command runs in, resolving WSL's default on first use.
249
- * @returns the distro name.
250
- */
251
- async distro() {
252
- if (this.resolvedDistro.length === 0) this.resolvedDistro = await defaultDistro({ wslPath: this.config.wslPath });
253
- return this.resolvedDistro;
254
- }
255
-
256
- /**
257
- * The shell every command runs in: the configured one, else the login shell of
258
- * the distro user, resolved once and cached.
259
- * @returns an absolute path to the shell inside the distro.
260
- */
261
- async shellName() {
262
- // Consumed by the shell tool after upstream change A: it names and describes
263
- // itself from the executor, so a zsh distro is never presented as bash.
264
- const shell = await this.shell();
265
- const base = String(shell ?? "").split("/").pop() ?? "";
266
- const name = base.replace(/\.exe$/i, "").replace(/^[-.]+/, "");
267
- return /^[a-z0-9_+-]+$/i.test(name) ? name : "bash";
268
- }
269
-
270
- async shell() {
271
- if (this.resolvedShell.length === 0) {
272
- const distro = await this.distro();
273
- // An unresolvable shell is a retry, not a pin: a failed probe (a cold
274
- // distro, a flaky first wsl.exe call) used to cache the "bash"
275
- // placeholder for this executor's lifetime. The runtime fallback below
276
- // keeps the command running; a later call resolves the real shell.
277
- const resolved = await defaultShell(distro, { wslPath: this.config.wslPath });
278
- if (resolved) this.resolvedShell = resolved;
279
- return resolved ?? "bash";
302
+ executionAgent(distro) {
303
+ return sharedAgent(distro);
280
304
  }
281
- return this.resolvedShell;
282
- }
283
-
284
- /** Windows directory the `wsl.exe` process is started from. */
285
- hostCwd() {
286
- return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
287
- }
288
-
289
- /**
290
- * The shared resident agent serving this executor's distro.
291
- * @param distro - the resolved distro name.
292
- * @returns {WslAgent} the shared agent.
293
- */
294
- executionAgent(distro) {
295
- return sharedAgent(distro);
296
- }
297
-
298
- /**
299
- * The argv that runs INSIDE the distro for one spec: the resolved shell, its
300
- * login/command flags, and the command string. This is the argv a
301
- * distro-side sandbox wraps, which is why it is built separately from the
302
- * `wsl.exe` invocation around it.
303
- *
304
- * @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
305
- * @param shell - the resolved distro shell.
306
- * @returns the distro-side argv.
307
- */
308
- distroArgv(spec, shell) {
309
- return [shell, ...shellArgs(shell, this.config.loginShell), spec.command];
310
- }
311
-
312
- /**
313
- * Build the `wsl.exe` argv for one resolved spec. The command string is
314
- * passed as a single argv element to the resolved shell, so there is no
315
- * quoting layer of our own; Node's argv array reaches `wsl.exe` verbatim.
316
- *
317
- * `--exec` is load-bearing — see the comment inside.
318
- *
319
- * @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
320
- * @param distro - the resolved distro name.
321
- * @param shell - the resolved distro shell.
322
- * @param inner - the already-confined distro argv, when confinement applies.
323
- * @returns the argv to spawn.
324
- */
325
- argv(spec, distro, shell, inner) {
326
- const distroArgs = distro.length > 0 ? ["-d", distro] : [];
327
- return [
328
- this.config.wslPath,
329
- ...distroArgs,
330
- "--cd",
331
- toLinuxPath(spec.workdir, { distro }),
332
- // `--exec` is essential, not decoration. Without it wsl.exe hands the
333
- // command line to the distro's DEFAULT SHELL, which expands it once before
334
- // our own shell ever sees it: `x=1; echo "$x"` arrives as `x=1; echo ""`,
335
- // `$?` becomes the outer shell's status, and `$(...)`, `${...}`, `$1` and
336
- // backticks are eaten the same way. With `--exec` the argv is passed
337
- // straight through, so the command string reaches the shell verbatim.
338
- //
339
- // Note the shell is named explicitly here even though `--exec` plus the
340
- // distro's default shell would look equivalent: without `--exec` we get the
341
- // double parse above, so the shell has to be resolved by us instead.
342
- "--exec",
343
- ...(inner ?? this.distroArgv(spec, shell)),
344
- ];
345
- }
346
-
347
- /**
348
- * Override only the spawn's working directory and environment. `spec.workdir`
349
- * is a Linux path, which a Windows CreateProcess call cannot use as `cwd`.
350
- *
351
- * @param spec - resolved execution settings.
352
- * @param argv - the argv from {@link WslShellExecutor.argv}.
353
- * @param stdoutMaxBytes - per-call stdout cap.
354
- * @param signal - the spawn cancellation signal.
355
- * @returns the subprocess spawn spec.
356
- */
357
- spawnSpec(spec, argv, stdoutMaxBytes, signal) {
358
- const base = super.spawnSpec(spec, argv, stdoutMaxBytes, signal);
359
- const forward = new Set([...SAFE_FORWARD, ...this.config.forwardEnv]);
360
- const forwarded = Object.keys(base.env ?? {}).filter(
361
- (name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX),
362
- );
363
- return {
364
- ...base,
365
- cwd: this.hostCwd(),
366
- env: {
367
- ...base.env,
368
- // wsl.exe writes its own diagnostics as UTF-16LE unless this is set, and a
369
- // UTF-16 message read as UTF-8 is mojibake — including the `WSL_E_*` codes
370
- // that say why a command could not start. The distro-missing check below
371
- // reads those codes, so the encoding has to be pinned here.
372
- WSL_UTF8: "1",
373
- // WSL imports only the names listed in WSLENV. The managed DSH_* facts are
374
- // included by prefix so every contributed variable rides along. PATH is deliberately
375
- // never listed: putting it here would replace the distro's PATH with
376
- // the Windows one instead of appending the interop entries WSL adds
377
- // on its own.
378
- ...(forwarded.length > 0 ? { WSLENV: forwarded.map((name) => (PATH_TRANSLATED.has(name) ? `${name}/p` : name)).join(":") } : {}),
379
- },
380
- };
381
- }
382
-
383
- /**
384
- * Resolve a request into a spec, replacing the parent's `process.cwd()`
385
- * fallback with this provider's distro-home marker and stamping the per-call
386
- * sandbox policy.
387
- *
388
- * The seam requires this step to be synchronous — the tool layer calls
389
- * `ctx.shell.execute(ctx.shell.resolve(…))` with no `await` between them — so
390
- * neither the home nor a confinement decision can be made here. Two request
391
- * shapes default to the home when no `cwd` is configured: one that names no
392
- * `workdir` at all, and one that names it only RELATIVELY — the parent
393
- * resolves the first against `process.cwd()` (a Windows directory) and has
394
- * nothing of its own to join the second onto. Both are stamped as the empty
395
- * marker, with a relative tail carried verbatim in `relativeWorkdir`;
396
- * {@link withDefaultWorkdir} resolves the pair against the home. The policy
397
- * travels on the spec to {@link execute}, which awaits the wrap.
398
- *
399
- * @param request - the caller's request.
400
- * @returns the fully-resolved spec.
401
- */
402
- resolve(request) {
403
- const spec = super.resolve(request);
404
- const sandboxPolicy = this.sandbox === undefined ? undefined : (request?.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve());
405
- const workdir = typeof request?.workdir === "string" ? request.workdir : undefined;
406
- const needsHome =
407
- configuredCwd(this.config).length === 0 && (workdir === undefined || isRelativeWorldPath(workdir));
408
- if (!needsHome && sandboxPolicy === undefined) return spec;
409
- return {
410
- ...spec,
411
- ...(needsHome ? { workdir: "", relativeWorkdir: workdir } : {}),
412
- ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
413
- };
414
- }
415
-
416
- /**
417
- * The spec to execute, with the home marker resolved against the distro
418
- * user's home: `workdir: ""` becomes the home itself, and a relative tail
419
- * carried by {@link WslShellExecutor.resolve} is joined under it. That is one
420
- * `sh -c 'printf %s "$HOME"'` per provider instance, cached, and it happens
421
- * only for a request that defaulted — an absolute workdir never reaches here.
422
- *
423
- * @param spec - the resolved spec.
424
- * @returns the spec to execute.
425
- */
426
- async withDefaultWorkdir(spec) {
427
- const tail = typeof spec?.relativeWorkdir === "string" ? spec.relativeWorkdir : undefined;
428
- if (String(spec?.workdir ?? "").trim().length > 0 && tail === undefined) return spec;
429
- const home = await distroHome(this);
430
- if (tail === undefined || tail.length === 0) return { ...spec, workdir: home };
431
- return { ...spec, workdir: toLinuxPath(tail, { distro: await this.distro(), cwd: home }) };
432
- }
433
-
434
- /**
435
- * Decorate a handle's foreground projection in place, memoized once. The
436
- * handle keeps its identity — never wrapped in a second object — because the
437
- * per-process facts and `onProcessDone` key on the exact instance.
438
- *
439
- * @param ex - the execution handle to decorate.
440
- * @param map - maps the settled result.
441
- * @param mapError - maps a rejection, when the caller has one.
442
- * @returns the same handle.
443
- */
444
- static decorateResult(ex, map, mapError) {
445
- const base = ex.result.bind(ex);
446
- let decorated;
447
- ex.result = () => {
448
- decorated ??= base().then(map, mapError);
449
- return decorated;
450
- };
451
- return ex;
452
- }
453
-
454
- /**
455
- * Stamp the per-process sandbox facts before `done` settles, so a background
456
- * process reports the same mode, denial and enforcement a foreground run
457
- * would. Signal deaths are not denials.
458
- */
459
- onProcessDone(proc, stderr, providerRejected, providerError) {
460
- const facts = this.processFacts.get(proc);
461
- if (facts !== undefined) {
462
- this.processFacts.delete(proc);
463
- // A provider rejection exposes no public failure stage; attribute it to
464
- // the confinement runner only when the error independently names argv[0].
465
- const runnerFailed = providerRejected
466
- ? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
467
- : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined;
468
- proc.sandbox = {
469
- mode: facts.mode,
470
- denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
471
- enforcement: facts.enforcement,
472
- ...(facts.windowsDrive ? { windowsDrive: facts.windowsDrive } : {}),
473
- ...(runnerFailed ? { runnerFailed } : {}),
474
- };
305
+ /**
306
+ * The argv that runs INSIDE the distro for one spec: the resolved shell, its
307
+ * login/command flags, and the command string. This is the argv a
308
+ * distro-side sandbox wraps, which is why it is built separately from the
309
+ * `wsl.exe` invocation around it.
310
+ *
311
+ * @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
312
+ * @param shell - the resolved distro shell.
313
+ * @returns the distro-side argv.
314
+ */
315
+ distroArgv(spec, shell) {
316
+ const config = this.config;
317
+ return [shell, ...shellArgs(shell, config.loginShell), spec.command];
475
318
  }
476
- super.onProcessDone(proc, stderr, providerRejected, providerError);
477
- }
478
-
479
- /**
480
- * Execute one resolved spec inside the distro, confined by the per-call policy
481
- * when there is one.
482
- *
483
- * Confinement wraps the DISTRO-side argv (`<shell> -lc <command>`) in `bwrap`
484
- * and hands the result to `wsl.exe --exec`, so the sandbox is built inside the
485
- * distro while the process that spawns it stays the ordinary Windows one. The
486
- * `danger-full-access` escalation — and an operator's `sandbox: false` — skip
487
- * the wrap entirely and run the command as before.
488
- *
489
- * @param spec - resolved execution settings.
490
- * @returns the live shell process handle.
491
- */
492
- /**
493
- * Reject a settled command that failed because the distro itself is missing,
494
- * stopped or unregistered.
495
- *
496
- * `wsl.exe` exits non-zero for that with an EMPTY stderr and its diagnostic on
497
- * stdout, so nothing else here can see it: the denial signatures read stderr, and
498
- * the result would otherwise reach the model as a bare non-zero code with no
499
- * cause. The message names the remedy and says plainly that a wider permission
500
- * cannot help — a sandbox flag does not create a distro.
501
- *
502
- * @param result - the settled shell result.
503
- * @param distro - the distro the command asked for.
504
- * @throws Error carrying the `WSL_E_*` code and the remedy.
505
- */
506
- throwIfDistroUnavailable(result, distro) {
507
- if (result?.exitCode === 0) return;
508
- const code = wslErrorCode(result?.stdout?.text, result?.stderr?.text);
509
- if (code === undefined) return;
510
- throw new Error(
511
- `wsl.exe reported ${code} for distro "${distro}": it is not installed, or not running. ` +
512
- `Start it with \`wsl.exe -d ${distro}\`, check \`wsl.exe -l -v\`, or point \`distro\` at another one. ` +
513
- `This is not a sandbox denial, so running with wider permissions will not help.`,
514
- );
515
- }
516
-
517
- /**
518
- * Reject a command that ran in the wrong directory.
519
- *
520
- * `wsl.exe --cd <path>` does not fail on a missing directory: it warns on stderr,
521
- * runs the command in `/`, and exits 0. Reporting that as success would let a write
522
- * or a build act on the wrong tree while every signal says it worked, so it becomes
523
- * an error naming the directory that could not be entered.
524
- *
525
- * @param result - the settled shell result.
526
- * @param workdir - the Linux directory the request asked for.
527
- * @throws Error naming the directory and the fallback that happened.
528
- */
529
- throwIfWorkdirMissing(result, workdir) {
530
- const missing = workdirFailure(result?.stdout?.text, result?.stderr?.text);
531
- if (missing === undefined) return;
532
- throw new Error(
533
- `wsl.exe could not enter the working directory "${missing}" inside the distro, so the command ran from "/" instead. ` +
534
- `Create it, or point \`cwd\` (or the request's \`workdir\`) at a directory that exists.`,
535
- );
536
- }
537
-
538
- async execute(spec) {
539
- const distro = await this.distro();
540
- const shell = await this.shell();
541
- const resolved = await this.withDefaultWorkdir(spec);
542
- this.throwIfWorkdirInAnotherDistro(resolved.workdir, distro);
543
- if (this.config.agent) {
544
- try {
545
- return await this.executeViaAgent(resolved, distro, shell);
546
- } catch (error) {
547
- // Only an out-of-service agent falls back; anything else (a cwd
548
- // refusal, a sandbox refusal) is the command's own verdict.
549
- if (!(error instanceof AgentUnavailableError)) throw error;
550
- }
319
+ /**
320
+ * Build the `wsl.exe` argv for one resolved spec. The command string is
321
+ * passed as a single argv element to the resolved shell, so there is no
322
+ * quoting layer of our own; Node's argv array reaches `wsl.exe` verbatim.
323
+ *
324
+ * `--exec` is load-bearing — see the comment inside.
325
+ *
326
+ * @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
327
+ * @param distro - the resolved distro name.
328
+ * @param shell - the resolved distro shell.
329
+ * @param inner - the already-confined distro argv, when confinement applies.
330
+ * @returns the argv to spawn.
331
+ */
332
+ argv(spec, distro, shell, inner) {
333
+ const config = this.config;
334
+ const distroArgs = distro.length > 0 ? ["-d", distro] : [];
335
+ return [
336
+ config.wslPath,
337
+ ...distroArgs,
338
+ "--cd",
339
+ toLinuxPath(spec.workdir, { distro }),
340
+ // `--exec` is essential, not decoration. Without it wsl.exe hands the
341
+ // command line to the distro's DEFAULT SHELL, which expands it once before
342
+ // our own shell ever sees it: `x=1; echo "$x"` arrives as `x=1; echo ""`,
343
+ // `$?` becomes the outer shell's status, and `$(...)`, `${...}`, `$1` and
344
+ // backticks are eaten the same way. With `--exec` the argv is passed
345
+ // straight through, so the command string reaches the shell verbatim.
346
+ //
347
+ // Note the shell is named explicitly here even though `--exec` plus the
348
+ // distro's default shell would look equivalent: without `--exec` we get the
349
+ // double parse above, so the shell has to be resolved by us instead.
350
+ "--exec",
351
+ ...(inner ?? this.distroArgv(spec, shell)),
352
+ ];
353
+ }
354
+ /**
355
+ * Override only the spawn's working directory and environment. `spec.workdir`
356
+ * is a Linux path, which a Windows CreateProcess call cannot use as `cwd`.
357
+ *
358
+ * The inherited builder is private, so the call goes through the
359
+ * {@link SpawnSeamExecutor} bridge (`inheritedSpawnSpec`) instead of `super`
360
+ * — see docs/UPSTREAM-SPAWN-SEAM.md.
361
+ *
362
+ * @param spec - resolved execution settings.
363
+ * @param argv - the argv from {@link WslShellExecutor.argv}.
364
+ * @param stdoutMaxBytes - per-call stdout cap.
365
+ * @param signal - the spawn cancellation signal.
366
+ * @returns the subprocess spawn spec.
367
+ */
368
+ spawnSpec(spec, argv, stdoutMaxBytes, signal) {
369
+ const config = this.config;
370
+ const base = inheritedSpawnSpec.call(this, spec, argv, stdoutMaxBytes, signal);
371
+ const forward = new Set([...SAFE_FORWARD, ...config.forwardEnv]);
372
+ const forwarded = Object.keys(base.env ?? {}).filter((name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX));
373
+ return {
374
+ ...base,
375
+ cwd: this.hostCwd(),
376
+ env: {
377
+ ...base.env,
378
+ // wsl.exe writes its own diagnostics as UTF-16LE unless this is set, and a
379
+ // UTF-16 message read as UTF-8 is mojibake — including the `WSL_E_*` codes
380
+ // that say why a command could not start. The distro-missing check below
381
+ // reads those codes, so the encoding has to be pinned here.
382
+ WSL_UTF8: "1",
383
+ // WSL imports only the names listed in WSLENV. The managed DSH_* facts are
384
+ // included by prefix so every contributed variable rides along. PATH is deliberately
385
+ // never listed: putting it here would replace the distro's PATH with
386
+ // the Windows one instead of appending the interop entries WSL adds
387
+ // on its own.
388
+ ...(forwarded.length > 0 ? { WSLENV: forwarded.map((name) => (PATH_TRANSLATED.has(name) ? `${name}/p` : name)).join(":") } : {}),
389
+ },
390
+ };
391
+ }
392
+ /**
393
+ * Resolve a request into a spec, replacing the parent's `process.cwd()`
394
+ * fallback with this provider's distro-home marker and stamping the per-call
395
+ * sandbox policy.
396
+ *
397
+ * The seam requires this step to be synchronous — the tool layer calls
398
+ * `ctx.shell.execute(ctx.shell.resolve(…))` with no `await` between them — so
399
+ * neither the home nor a confinement decision can be made here. Two request
400
+ * shapes default to the home when no `cwd` is configured: one that names no
401
+ * `workdir` at all, and one that names it only RELATIVELY — the parent
402
+ * resolves the first against `process.cwd()` (a Windows directory) and has
403
+ * nothing of its own to join the second onto. Both are stamped as the empty
404
+ * marker, with a relative tail carried verbatim in `relativeWorkdir`;
405
+ * {@link withDefaultWorkdir} resolves the pair against the home. The policy
406
+ * travels on the spec to {@link execute}, which awaits the wrap.
407
+ *
408
+ * @param request - the caller's request.
409
+ * @returns the fully-resolved spec.
410
+ */
411
+ resolve(request) {
412
+ const config = this.config;
413
+ const spec = super.resolve(request);
414
+ const sandboxPolicy = this.sandbox === undefined ? undefined : (request?.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve());
415
+ const workdir = typeof request?.workdir === "string" ? request.workdir : undefined;
416
+ const needsHome = configuredCwd(config).length === 0 && (workdir === undefined || isRelativeWorldPath(workdir));
417
+ if (!needsHome && sandboxPolicy === undefined)
418
+ return spec;
419
+ return {
420
+ ...spec,
421
+ ...(needsHome ? { workdir: "", relativeWorkdir: workdir } : {}),
422
+ ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
423
+ };
424
+ }
425
+ /**
426
+ * The spec to execute, with the home marker resolved against the distro
427
+ * user's home: `workdir: ""` becomes the home itself, and a relative tail
428
+ * carried by {@link WslShellExecutor.resolve} is joined under it. That is one
429
+ * `sh -c 'printf %s "$HOME"'` per provider instance, cached, and it happens
430
+ * only for a request that defaulted — an absolute workdir never reaches here.
431
+ *
432
+ * @param spec - the resolved spec.
433
+ * @returns the spec to execute.
434
+ */
435
+ async withDefaultWorkdir(spec) {
436
+ const tail = typeof spec?.relativeWorkdir === "string" ? spec.relativeWorkdir : undefined;
437
+ if (String(spec?.workdir ?? "").trim().length > 0 && tail === undefined)
438
+ return spec;
439
+ const home = await distroHome(this);
440
+ if (tail === undefined || tail.length === 0)
441
+ return { ...spec, workdir: home };
442
+ return { ...spec, workdir: toLinuxPath(tail, { distro: await this.distro(), cwd: home }) };
443
+ }
444
+ /**
445
+ * Decorate a handle's foreground projection in place, memoized once. The
446
+ * handle keeps its identity — never wrapped in a second object — because the
447
+ * per-process facts and `onProcessDone` key on the exact instance.
448
+ *
449
+ * @template H - the concrete handle type, preserved so the decorated handle
450
+ * keeps its identity in the callers' types; the settled result row the maps
451
+ * are spelled in is derived from its `result()`.
452
+ * @param ex - the execution handle to decorate.
453
+ * @param map - maps the settled result.
454
+ * @param mapError - maps a rejection, when the caller has one.
455
+ * @returns the same handle.
456
+ */
457
+ static decorateResult(ex, map, mapError) {
458
+ const handle = ex;
459
+ const base = handle.result.bind(handle);
460
+ let decorated;
461
+ handle.result = () => {
462
+ decorated ??= base().then(map, mapError);
463
+ return decorated;
464
+ };
465
+ return ex;
551
466
  }
552
- return this.executeOneShot(resolved, distro, shell);
553
- }
554
-
555
- /**
556
- * Refuse a workdir that names another WSL distro — the shell-side twin of
557
- * the filesystem's `FS_OUTSIDE_DISTRO` fence. Left unguarded, `toLinuxPath`
558
- * strips the UNC's distro name and the command runs inside THIS distro
559
- * against whatever tree happens to share the path, silently, while the fs
560
- * side refuses the same workspace outright. The wording mirrors the fs
561
- * refusal (minus `restrictToDistro`, which is an fs-only key).
562
- * @param workdir - the resolved workdir (UNC, Linux path, or Windows path).
563
- * @param distro - the pinned distro name.
564
- * @throws Error naming both distros and both ways out.
565
- */
566
- throwIfWorkdirInAnotherDistro(workdir, distro) {
567
- if (!isAnotherDistrosUnc(workdir, distro)) return;
568
- throw new Error(
569
- `"${workdir}" names a workspace in another WSL distro, and this profile pins its commands to "${distro}" — ` +
570
- `running them here would act on a different machine's tree. Open a session in that distro, ` +
571
- `or point cwd/workdir at this distro.`,
572
- );
573
- }
574
-
575
- /**
576
- * The agent-backed execution path: one long-lived in-distro process, the
577
- * confinement argv unchanged, the handle shape unchanged. Any
578
- * {@link AgentUnavailableError} propagates for the caller's fallback.
579
- * @param resolved - the resolved spec from {@link WslShellExecutor.execute}.
580
- * @param distro - the resolved distro name.
581
- * @param shell - the resolved distro shell.
582
- * @returns the decorated execution result.
583
- */
584
- async executeViaAgent(resolved, distro, shell) {
585
- const policy = resolved.sandboxPolicy;
586
- const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
587
- ? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
588
- : undefined;
589
- const mode = policy?.mode;
590
- const execution = agentExecutionHandle({
591
- agent: await this.executionAgent(distro),
592
- // The one-shot path converts the spec's workdir into the distro's
593
- // coordinate system inside argv(); the agent carries the cwd as a frame
594
- // field, so the SAME conversion happens here — a raw UNC must never
595
- // reach `chdir` (that is the bug this line fixes).
596
- cwd: toLinuxPath(resolved.workdir, { distro }),
597
- argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
598
- timeoutMs: resolved.timeoutMs,
599
- // The agent enforces the collect budget distro-side (the EXEC frame
600
- // carries it); `maxSpillBytes` stays one-shot-only — the agent path has
601
- // no spill files by design, its capture is bounded instead.
602
- maxOutputBytes: this.config.maxOutputBytes,
603
- signal: resolved.signal,
604
- onStarted: confined !== undefined
605
- ? (proc) => {
606
- this.processFacts.set(proc, {
607
- mode,
608
- enforcement: confined.enforcement,
609
- denialSignatures: confined.denialSignatures,
610
- runnerFailureRules: confined.runnerFailureRules,
611
- runnerProgram: confined.argv[0],
612
- workdir: resolved.workdir,
613
- windowsDrive: confined.windowsDrive,
467
+ /**
468
+ * Stamp the per-process sandbox facts before `done` settles, so a background
469
+ * process reports the same mode, denial and enforcement a foreground run
470
+ * would. Signal deaths are not denials.
471
+ *
472
+ * @param proc - the settled process handle.
473
+ * @param stderr - the process's retained stderr tail used by subclasses for settlement classification.
474
+ * @param providerRejected - whether the subprocess promise rejected without a direct outcome.
475
+ * @param providerError - the provider rejection reason, which may itself be undefined.
476
+ */
477
+ onProcessDone(proc, stderr, providerRejected, providerError) {
478
+ const facts = this.processFacts.get(proc);
479
+ if (facts !== undefined) {
480
+ this.processFacts.delete(proc);
481
+ // A provider rejection exposes no public failure stage; attribute it to
482
+ // the confinement runner only when the error independently names argv[0].
483
+ const runnerFailed = providerRejected
484
+ ? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
485
+ : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined;
486
+ proc.sandbox = {
487
+ mode: facts.mode,
488
+ denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
489
+ enforcement: facts.enforcement,
490
+ ...(facts.windowsDrive ? { windowsDrive: facts.windowsDrive } : {}),
491
+ ...(runnerFailed ? { runnerFailed } : {}),
492
+ };
493
+ }
494
+ super.onProcessDone(proc, stderr, providerRejected, providerError);
495
+ }
496
+ /**
497
+ * Reject a settled command that failed because the distro itself is missing,
498
+ * stopped or unregistered.
499
+ *
500
+ * `wsl.exe` exits non-zero for that with an EMPTY stderr and its diagnostic on
501
+ * stdout, so nothing else here can see it: the denial signatures read stderr, and
502
+ * the result would otherwise reach the model as a bare non-zero code with no
503
+ * cause. The message names the remedy and says plainly that a wider permission
504
+ * cannot help — a sandbox flag does not create a distro.
505
+ *
506
+ * @param result - the settled shell result.
507
+ * @param distro - the distro the command asked for.
508
+ * @throws {Error} carrying the `WSL_E_*` code and the remedy.
509
+ */
510
+ throwIfDistroUnavailable(result, distro) {
511
+ if (result?.exitCode === 0)
512
+ return;
513
+ const code = wslErrorCode(result?.stdout?.text, result?.stderr?.text);
514
+ if (code === undefined)
515
+ return;
516
+ throw new Error(`wsl.exe reported ${code} for distro "${distro}": it is not installed, or not running. ` +
517
+ `Start it with \`wsl.exe -d ${distro}\`, check \`wsl.exe -l -v\`, or point \`distro\` at another one. ` +
518
+ `This is not a sandbox denial, so running with wider permissions will not help.`);
519
+ }
520
+ /**
521
+ * Reject a command that ran in the wrong directory.
522
+ *
523
+ * `wsl.exe --cd <path>` does not fail on a missing directory: it warns on stderr,
524
+ * runs the command in `/`, and exits 0. Reporting that as success would let a write
525
+ * or a build act on the wrong tree while every signal says it worked, so it becomes
526
+ * an error naming the directory that could not be entered.
527
+ *
528
+ * @param result - the settled shell result.
529
+ * @param workdir - the Linux directory the request asked for.
530
+ * @throws {Error} naming the directory and the fallback that happened.
531
+ */
532
+ throwIfWorkdirMissing(result, workdir) {
533
+ const missing = workdirFailure(result?.stdout?.text, result?.stderr?.text);
534
+ if (missing === undefined)
535
+ return;
536
+ throw new Error(`wsl.exe could not enter the working directory "${missing}" inside the distro, so the command ran from "/" instead. ` +
537
+ `Create it, or point \`cwd\` (or the request's \`workdir\`) at a directory that exists.`);
538
+ }
539
+ /**
540
+ * Execute one resolved spec inside the distro, confined by the per-call policy
541
+ * when there is one.
542
+ *
543
+ * Confinement wraps the DISTRO-side argv (`<shell> -lc <command>`) in `bwrap`
544
+ * and hands the result to `wsl.exe --exec`, so the sandbox is built inside the
545
+ * distro while the process that spawns it stays the ordinary Windows one. The
546
+ * `danger-full-access` escalation — and an operator's `sandbox: false` — skip
547
+ * the wrap entirely and run the command as before.
548
+ *
549
+ * @param spec - resolved execution settings.
550
+ * @returns the live shell process handle.
551
+ */
552
+ async execute(spec) {
553
+ const config = this.config;
554
+ const distro = await this.distro();
555
+ const shell = await this.shell();
556
+ const resolved = await this.withDefaultWorkdir(spec);
557
+ this.throwIfWorkdirInAnotherDistro(resolved.workdir, distro);
558
+ if (config.agent) {
559
+ try {
560
+ return await this.executeViaAgent(resolved, distro, shell);
561
+ }
562
+ catch (error) {
563
+ // Only an out-of-service agent falls back; anything else (a cwd
564
+ // refusal, a sandbox refusal) is the command's own verdict.
565
+ if (!(error instanceof AgentUnavailableError))
566
+ throw error;
567
+ }
568
+ }
569
+ return this.executeOneShot(resolved, distro, shell);
570
+ }
571
+ /**
572
+ * Refuse a workdir that names another WSL distro — the shell-side twin of
573
+ * the filesystem's `FS_OUTSIDE_DISTRO` fence. Left unguarded, `toLinuxPath`
574
+ * strips the UNC's distro name and the command runs inside THIS distro
575
+ * against whatever tree happens to share the path, silently, while the fs
576
+ * side refuses the same workspace outright. The wording mirrors the fs
577
+ * refusal (minus `restrictToDistro`, which is an fs-only key).
578
+ *
579
+ * @param workdir - the resolved workdir (UNC, Linux path, or Windows path).
580
+ * @param distro - the pinned distro name.
581
+ * @throws {Error} naming both distros and both ways out.
582
+ */
583
+ throwIfWorkdirInAnotherDistro(workdir, distro) {
584
+ if (!isAnotherDistrosUnc(workdir, distro))
585
+ return;
586
+ throw new Error(`"${workdir}" names a workspace in another WSL distro, and this profile pins its commands to "${distro}" — ` +
587
+ `running them here would act on a different machine's tree. Open a session in that distro, ` +
588
+ `or point cwd/workdir at this distro.`);
589
+ }
590
+ /**
591
+ * The agent-backed execution path: one long-lived in-distro process, the
592
+ * confinement argv unchanged, the handle shape unchanged. Any
593
+ * {@link AgentUnavailableError} propagates for the caller's fallback.
594
+ *
595
+ * @param resolved - the resolved spec from {@link WslShellExecutor.execute}.
596
+ * @param distro - the resolved distro name.
597
+ * @param shell - the resolved distro shell.
598
+ * @returns the decorated execution result.
599
+ */
600
+ async executeViaAgent(resolved, distro, shell) {
601
+ const config = this.config;
602
+ const policy = resolved.sandboxPolicy;
603
+ const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
604
+ ? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
605
+ : undefined;
606
+ const mode = policy?.mode;
607
+ const execution = agentExecutionHandle({
608
+ agent: await this.executionAgent(distro),
609
+ // The one-shot path converts the spec's workdir into the distro's
610
+ // coordinate system inside argv(); the agent carries the cwd as a frame
611
+ // field, so the SAME conversion happens here — a raw UNC must never
612
+ // reach `chdir` (that is the bug this line fixes).
613
+ cwd: toLinuxPath(resolved.workdir, { distro }),
614
+ argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
615
+ timeoutMs: resolved.timeoutMs,
616
+ // The agent enforces the collect budget distro-side (the EXEC frame
617
+ // carries it); `maxSpillBytes` stays one-shot-only — the agent path has
618
+ // no spill files by design, its capture is bounded instead. The budget
619
+ // is the executor's own per-stream cap, read through the volatile
620
+ // wrapper like every other volatile field: handing the wrapper over raw
621
+ // made `encodeExecFrame`'s `> 0` test false, so the frame carried 0 —
622
+ // which the agent reads as "uncapped", and the documented cut never ran.
623
+ maxOutputBytes: config.maxOutputBytes.get(),
624
+ signal: resolved.signal,
625
+ onStarted: confined !== undefined
626
+ ? (proc) => {
627
+ // The untyped guard cannot narrow the captured profile inside the
628
+ // callback, so the confined shape is asserted once here.
629
+ const profile = confined;
630
+ this.processFacts.set(proc, {
631
+ mode,
632
+ enforcement: profile.enforcement,
633
+ denialSignatures: profile.denialSignatures,
634
+ runnerFailureRules: profile.runnerFailureRules,
635
+ runnerProgram: profile.argv[0] ?? "",
636
+ workdir: resolved.workdir,
637
+ windowsDrive: profile.windowsDrive,
638
+ });
639
+ }
640
+ : undefined,
641
+ });
642
+ const decorated = WslShellExecutor.decorateResult(execution, (result) => {
643
+ this.throwIfDistroUnavailable(result, distro);
644
+ this.throwIfWorkdirMissing(result, resolved.workdir);
645
+ if (this.sandbox === undefined)
646
+ return result;
647
+ if (confined === undefined) {
648
+ return ({ ...result, sandbox: { mode, denied: false } });
649
+ }
650
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
651
+ const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
652
+ if (runnerFailure !== undefined) {
653
+ // This path computes `mode` as `policy?.mode`, which the seam spells as
654
+ // the wider union: a runner failure is only ever thrown under a
655
+ // confinement, and there the mode is one of the two confined ones.
656
+ throw new SandboxUnavailableError(mode, withBwrapHint(runnerFailure.detail, distro));
657
+ }
658
+ return ({
659
+ ...result,
660
+ sandbox: {
661
+ mode,
662
+ denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
663
+ enforcement,
664
+ windowsDrive,
665
+ },
614
666
  });
615
- }
616
- : undefined,
617
- });
618
- return WslShellExecutor.decorateResult(execution, (result) => {
619
- this.throwIfDistroUnavailable(result, distro);
620
- this.throwIfWorkdirMissing(result, resolved.workdir);
621
- if (this.sandbox === undefined) return result;
622
- if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
623
- const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
624
- const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
625
- if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
626
- return {
627
- ...result,
628
- sandbox: {
629
- mode,
630
- denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
631
- enforcement,
632
- windowsDrive,
633
- },
634
- };
635
- }, (error) => {
636
- if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
637
- throw error;
638
- });
639
- }
640
-
641
- /**
642
- * The pre-agent execution path, verbatim: one `wsl.exe` per command through
643
- * the inherited `executeArgv` machinery. Kept as the permanent fallback for
644
- * an out-of-service agent and as the `agent: false` behaviour.
645
- * @param resolved - the resolved spec.
646
- * @param distro - the resolved distro name.
647
- * @param shell - the resolved distro shell.
648
- * @returns the decorated execution result.
649
- */
650
- async executeOneShot(resolved, distro, shell) {
651
- const policy = resolved.sandboxPolicy;
652
- if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
653
- // `executeArgv` resolves to the execution handle, so it has to be awaited
654
- // before it can be decorated — the confined branch below does the same.
655
- const execution = await this.executeArgv(resolved, () => this.argv(resolved, distro, shell));
656
- return WslShellExecutor.decorateResult(
657
- execution,
658
- (result) => {
659
- this.throwIfDistroUnavailable(result, distro);
660
- this.throwIfWorkdirMissing(result, resolved.workdir);
661
- return result;
662
- },
663
- (error) => {
664
- throw error;
665
- },
666
- );
667
+ }, (error) => {
668
+ if (resolved.signal?.aborted === true)
669
+ resolved.signal.throwIfAborted();
670
+ throw error;
671
+ });
672
+ // The agent handle IS the upstream execution shape at runtime; its typedef
673
+ // spells `signal` as `string` and the captured streams without `spillPath`,
674
+ // so the upstream-facing return rides one type-level cast.
675
+ return decorated;
667
676
  }
668
- const mode = policy.mode;
669
- let confined;
670
- const execution = await this.executeArgv(
671
- resolved,
672
- async (signal) => {
673
- // Preparation is async, so it runs under executeArgv's deadline and
674
- // cancellation handling rather than before them. A distro without a
675
- // usable runner throws here, before anything is spawned.
676
- confined = await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode }, { distro, signal });
677
- signal.throwIfAborted();
678
- return this.argv(resolved, distro, shell, confined.argv);
679
- },
680
- (process) => {
681
- this.processFacts.set(process, {
682
- mode,
683
- enforcement: confined.enforcement,
684
- denialSignatures: confined.denialSignatures,
685
- runnerFailureRules: confined.runnerFailureRules,
686
- runnerProgram: confined.argv[0],
687
- workdir: resolved.workdir,
688
- windowsDrive: confined.windowsDrive,
677
+ /**
678
+ * The pre-agent execution path, verbatim: one `wsl.exe` per command through
679
+ * the inherited `executeArgv` machinery. Kept as the permanent fallback for
680
+ * an out-of-service agent and as the `agent: false` behaviour.
681
+ *
682
+ * @param resolved - the resolved spec.
683
+ * @param distro - the resolved distro name.
684
+ * @param shell - the resolved distro shell.
685
+ * @returns the decorated execution result.
686
+ */
687
+ async executeOneShot(resolved, distro, shell) {
688
+ const policy = resolved.sandboxPolicy;
689
+ if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
690
+ // `executeArgv` resolves to the execution handle, so it has to be awaited
691
+ // before it can be decorated — the confined branch below does the same.
692
+ // The sync thunk is the prepare form at runtime (`executeArgv` awaits any
693
+ // function it is given); the type spells it as the async prepare it is
694
+ // used as, which is why the thunk rides a type-level cast.
695
+ const execution = await this.executeArgv(resolved, (() => this.argv(resolved, distro, shell)));
696
+ return WslShellExecutor.decorateResult(execution, (result) => {
697
+ this.throwIfDistroUnavailable(result, distro);
698
+ this.throwIfWorkdirMissing(result, resolved.workdir);
699
+ return result;
700
+ }, (error) => {
701
+ throw error;
702
+ });
703
+ }
704
+ const mode = policy.mode;
705
+ let confined;
706
+ const execution = await this.executeArgv(resolved, async (signal) => {
707
+ // Preparation is async, so it runs under executeArgv's deadline and
708
+ // cancellation handling rather than before them. A distro without a
709
+ // usable runner throws here, before anything is spawned. The outer
710
+ // guard narrowed the sandbox in, but narrowings do not reach closures —
711
+ // so the confined sandbox is asserted here, type-level only.
712
+ confined = await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode }, { distro, signal });
713
+ signal.throwIfAborted();
714
+ return this.argv(resolved, distro, shell, confined.argv);
715
+ }, (process) => {
716
+ // The outer guard cannot narrow the captured profile inside the
717
+ // callback, so the confined shape is asserted once here.
718
+ const profile = confined;
719
+ this.processFacts.set(process, {
720
+ mode,
721
+ enforcement: profile.enforcement,
722
+ denialSignatures: profile.denialSignatures,
723
+ runnerFailureRules: profile.runnerFailureRules,
724
+ runnerProgram: profile.argv[0] ?? "",
725
+ workdir: resolved.workdir,
726
+ windowsDrive: profile.windowsDrive,
727
+ });
689
728
  });
690
- },
691
- );
692
- return WslShellExecutor.decorateResult(execution, (result) => {
693
- this.throwIfDistroUnavailable(result, distro);
694
- this.throwIfWorkdirMissing(result, resolved.workdir);
695
- if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
696
- const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
697
- // Runner failure outranks denial because the command did not run at all.
698
- const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
699
- if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
700
- return {
701
- ...result,
702
- sandbox: {
703
- mode,
704
- denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
705
- enforcement,
706
- windowsDrive,
707
- },
708
- };
709
- }, (error) => {
710
- // An upstream abort remains cancellation even when it prevents spawn.
711
- if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
712
- if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], resolved.workdir)) {
713
- throw new SandboxUnavailableError(mode, String(error));
714
- }
715
- throw error;
716
- });
717
- }
729
+ return WslShellExecutor.decorateResult(execution, (result) => {
730
+ this.throwIfDistroUnavailable(result, distro);
731
+ this.throwIfWorkdirMissing(result, resolved.workdir);
732
+ if (confined === undefined) {
733
+ return ({ ...result, sandbox: { mode, denied: false } });
734
+ }
735
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
736
+ // Runner failure outranks denial because the command did not run at all.
737
+ const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
738
+ if (runnerFailure !== undefined)
739
+ throw new SandboxUnavailableError(mode, withBwrapHint(runnerFailure.detail, distro));
740
+ return ({
741
+ ...result,
742
+ sandbox: {
743
+ mode,
744
+ denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
745
+ enforcement,
746
+ windowsDrive,
747
+ },
748
+ });
749
+ }, (error) => {
750
+ // An upstream abort remains cancellation even when it prevents spawn.
751
+ if (resolved.signal?.aborted === true)
752
+ resolved.signal.throwIfAborted();
753
+ if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], resolved.workdir)) {
754
+ throw new SandboxUnavailableError(mode, withBwrapHint(String(error), distro));
755
+ }
756
+ throw error;
757
+ });
758
+ }
718
759
  }
719
-
720
- /**
721
- * Filesystem backend over a WSL distro's UNC share.
722
- *
723
- * Reads, listings, stats and searches reuse the host backend verbatim: the
724
- * share is a real filesystem to Node, and the packaged ripgrep walks it too.
725
- * Two things are deliberately replaced.
726
- *
727
- * **Resolution** accepts Linux paths (`/home/andy/proj/src/a.ts`) and shows them
728
- * back as Linux paths, while `targetKey` stays the UNC path the host I/O uses —
729
- * the seam treats `targetKey` as opaque and reserves `displayPath` for model and
730
- * UI output, so this rewrite is contract-legal.
731
- *
732
- * **Mutation guards are hoisted.** The host backend publishes a guarded
733
- * *creation* by hard-linking a staged file into place, and the WSL UNC share
734
- * rejects hard links with `ENOTSUP`; it also refuses `symlink` with `EPERM`.
735
- * Guarded creation through the share would therefore fail with `FS_IO_ERROR` on
736
- * the most common write there is — creating a new file. This backend checks the
737
- * caller's guard itself and then delegates the unguarded publication, which the
738
- * share does support. The residual window is the same check-then-act race
739
- * `dsh-fs-sandbox` already documents and accepts.
740
- *
741
- * **Publication is replaced.** Unguarded is not enough on its own: the host
742
- * backend's Windows branch also copies the replaced file's security descriptor
743
- * and publishes with `ReplaceFileW`, and neither exists on a 9p share — the
744
- * descriptor read fails every write to an existing file outright. See the
745
- * constructor for what runs instead.
746
- */
747
760
  export class WslFileSystem extends LocalFileSystem {
748
- static inject = ["sandboxPolicy"];
749
-
750
- static Config = z.object({
751
- /** Distro name; empty resolves WSL's default on first use. */
752
- distro: z.string().default(""),
761
+ static inject = ["sandboxPolicy"];
762
+ static Config = z.object({
763
+ /** Distro name; empty resolves WSL's default on first use. */
764
+ distro: z.string().default(""),
765
+ /**
766
+ * Default workdir for relative input; accepts a Linux path, a UNC path, or
767
+ * `C:\...`. Empty — the default — means the distro user's home, resolved
768
+ * inside the distro.
769
+ */
770
+ cwd: z.string().default(""),
771
+ /** Path to `wsl.exe`, used only to resolve the default distro. */
772
+ wslPath: z.string().default("wsl.exe"),
773
+ /** UTF-8 byte limit per overwrite-diff side, mirroring the host backend. */
774
+ diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
775
+ /**
776
+ * Fence mutations by `ctx.sandboxPolicy`, and report the mode through
777
+ * {@link sandboxMode}. `false` restores the pre-sandbox behaviour exactly —
778
+ * an unfenced backend that reports `undefined` — for an operator who wants
779
+ * the old posture and accepts what it means: the model is told these
780
+ * operations are unconfined.
781
+ */
782
+ sandbox: z.boolean().default(true),
783
+ /**
784
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
785
+ * Windows drive from confined commands — same meaning and same honest
786
+ * limits as the `wsl-shell` key of the same name (its doc carries the
787
+ * full story). Confined fs mutations publish through the masked resident;
788
+ * reads and the share substrate are unaffected.
789
+ */
790
+ maskWindowsDrive: z.boolean().default(false),
791
+ /**
792
+ * Refuse any path that leaves the pinned distro. A WSL filesystem is a
793
+ * whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
794
+ * this the backend would silently widen the session far beyond the
795
+ * workspace the operator selected.
796
+ */
797
+ restrictToDistro: z.boolean().default(true),
798
+ /**
799
+ * How deep the in-distro watch scan walks, `1` = the watched directory and
800
+ * its direct children. `0` — the default — walks the whole tree, which is
801
+ * what the invalidation contract asks for but a real cost every poll over
802
+ * a tree containing a `node_modules`; a bound trades that cost for a
803
+ * documented blind spot: a change below the bound is not seen until
804
+ * another change above it fires the same callback.
805
+ */
806
+ watchMaxDepth: z.natural().default(0),
807
+ /**
808
+ * Which I/O substrate serves the file tools. Only `"agent"` exists — the
809
+ * resident in-distro agent (`lib/fs-substrate.js`): reads, writes and
810
+ * identities run where the files live, symlinks and mode bits are native,
811
+ * and the mutation guard survives to the write's no-replace publication,
812
+ * kernel-enforced by the confined resident when the policy is confined.
813
+ * The former `"share"` opt-out — the Windows-side host filesystem stack
814
+ * over the distro's 9p share — is retired: it was the one model-facing
815
+ * I/O path that still crossed the share, and the agent has no fallback
816
+ * that needs it. A profile carrying `"share"` is refused at construction
817
+ * with the migration (`shareSubstrateRefusal`). `watch()` rides the
818
+ * in-distro poll loop for the substrate regardless (`lib/watcher.js`) —
819
+ * it never ran through either substrate. The substrate never changes
820
+ * `sandboxMode`'s honesty.
821
+ */
822
+ substrate: z.union(["share", "agent"]).default("agent"),
823
+ });
753
824
  /**
754
- * Default workdir for relative input; accepts a Linux path, a UNC path, or
755
- * `C:\...`. Empty — the default — means the distro user's home, resolved
756
- * inside the distro.
825
+ * @param ctx - the composition context.
826
+ * @param config - the validated plugin config.
757
827
  */
758
- cwd: z.string().default(""),
759
- /** Path to `wsl.exe`, used only to resolve the default distro. */
760
- wslPath: z.string().default("wsl.exe"),
761
- /** UTF-8 byte limit per overwrite-diff side, mirroring the host backend. */
762
- diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
828
+ constructor(ctx, config) {
829
+ // The share substrate is refused before any peer work: a profile still
830
+ // carrying the retired value must fail loudly, with the migration, not
831
+ // silently serve the one I/O path this provider exists to close.
832
+ const refusal = shareSubstrateRefusal(config.substrate);
833
+ if (refusal !== null)
834
+ throw new Error(refusal.message);
835
+ super(ctx, config);
836
+ this.resolvedDistro = config.distro;
837
+ this.resolvedHome = undefined;
838
+ this.defaultMode = ctx.sandboxPolicy.defaultMode;
839
+ this.agentSubstrateInstance = undefined;
840
+ this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
841
+ this.agentInstance = undefined;
842
+ }
763
843
  /**
764
- * Fence mutations by `ctx.sandboxPolicy`, and report the mode through
765
- * {@link sandboxMode}. `false` restores the pre-sandbox behaviour exactly —
766
- * an unfenced backend that reports `undefined` — for an operator who wants
767
- * the old posture and accepts what it means: the model is told these
768
- * operations are unconfined.
844
+ * The mode this backend fences mutations with, or `undefined` when the
845
+ * operator turned the fence off. This is the capability fact `dsh-tool-fs`
846
+ * reads to advertise the escalation fields honestly, so it must disappear
847
+ * together with the enforcement — never claim a boundary that is not applied.
848
+ *
849
+ * @returns the deployment default mode, or `undefined` when unfenced.
769
850
  */
770
- sandbox: z.boolean().default(true),
851
+ get sandboxMode() {
852
+ const config = this.config;
853
+ return config.sandbox ? this.defaultMode : undefined;
854
+ }
771
855
  /**
772
- * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
773
- * Windows drive from confined commands — same meaning and same honest
774
- * limits as the `wsl-shell` key of the same name (its doc carries the
775
- * full story). Confined fs mutations publish through the masked resident;
776
- * reads and the share substrate are unaffected.
856
+ * The resident agent serving this filesystem's in-distro side work, created
857
+ * on first use and kept for the provider's lifetime (its own idle timer
858
+ * retires the process; a later call starts a fresh one).
859
+ *
860
+ * @returns the agent for the pinned distro.
777
861
  */
778
- maskWindowsDrive: z.boolean().default(false),
862
+ async publicationAgent() {
863
+ if (this.agentInstance === undefined) {
864
+ this.agentInstance = sharedAgent(await this.distro());
865
+ }
866
+ return this.agentInstance;
867
+ }
779
868
  /**
780
- * Refuse any path that leaves the pinned distro. A WSL filesystem is a
781
- * whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
782
- * this the backend would silently widen the session far beyond the
783
- * workspace the operator selected.
869
+ * The agent-backed substrate, built on first use over the same resident
870
+ * agent every other distro side work shares.
871
+ *
872
+ * @returns the substrate.
784
873
  */
785
- restrictToDistro: z.boolean().default(true),
874
+ async agentSubstrate() {
875
+ const config = this.config;
876
+ if (this.agentSubstrateInstance === undefined) {
877
+ this.agentSubstrateInstance = new AgentSubstrate({
878
+ agent: await this.publicationAgent(),
879
+ distro: await this.distro(),
880
+ diffBasisMaxBytes: config.diffBasisMaxBytes,
881
+ agentFor: (policy) => this.mutationAgent(policy),
882
+ });
883
+ }
884
+ return this.agentSubstrateInstance;
885
+ }
786
886
  /**
787
- * How deep the in-distro watch scan walks, `1` = the watched directory and
788
- * its direct children. `0` — the default — walks the whole tree, which is
789
- * what the invalidation contract asks for but a real cost every poll over
790
- * a tree containing a `node_modules`; a bound trades that cost for a
791
- * documented blind spot: a change below the bound is not seen until
792
- * another change above it fires the same callback.
887
+ * The agent a MUTATION runs on under this resolved policy — stage-two
888
+ * routing. A confined policy addresses the confined resident whose bwrap
889
+ * profile binds exactly what the mode grants, so the kernel enforces what
890
+ * the check decided; an escalated write (`danger-full-access`) or a disabled
891
+ * sandbox addresses the plain resident, which is what those mean. A distro
892
+ * without a usable bwrap refuses confined mutations instead of downgrading
893
+ * them — the same closed failure the command path has.
894
+ *
895
+ * @param policy - the resolved file-effect policy, or undefined when the
896
+ * caller pre-checked.
897
+ * @returns the agent to publish through.
793
898
  */
794
- watchMaxDepth: z.natural().default(0),
899
+ async mutationAgent(policy) {
900
+ const config = this.config;
901
+ if (!isConfinedMutation({ policy, sandboxEnabled: config.sandbox })) {
902
+ return this.publicationAgent();
903
+ }
904
+ // The untyped guard decided this is a confined policy but cannot narrow
905
+ // the parameter, so the confined shape is asserted once here (type-level
906
+ // only; `confinedPolicy` is the same value as `policy`).
907
+ const confinedPolicy = policy;
908
+ const distro = await this.distro();
909
+ if (!(await this.sandbox.usable(distro))) {
910
+ // The probe's classified remedy — missing vs present-but-broken, with the
911
+ // distro's own package family — is what every other refusal surfaces;
912
+ // this path has the same failure, so it carries the same text.
913
+ throw new SandboxUnavailableError(confinedPolicy.mode, bwrapFailure(config.wslPath, distro) ?? missingBwrapRemedy(distro));
914
+ }
915
+ return confinedAgent({ distro, wslPath: config.wslPath, policy: confinedPolicy, maskWindowsDrive: config.maskWindowsDrive });
916
+ }
795
917
  /**
796
- * Which I/O substrate serves the file tools. Only `"agent"` exists — the
797
- * resident in-distro agent (`lib/fs-substrate.js`): reads, writes and
798
- * identities run where the files live, symlinks and mode bits are native,
799
- * and the mutation guard survives to the write's no-replace publication,
800
- * kernel-enforced by the confined resident when the policy is confined.
801
- * The former `"share"` opt-out — the Windows-side host filesystem stack
802
- * over the distro's 9p share — is retired: it was the one model-facing
803
- * I/O path that still crossed the share, and the agent has no fallback
804
- * that needs it. A profile carrying `"share"` is refused at construction
805
- * with the migration (`shareSubstrateRefusal`). `watch()` rides the
806
- * in-distro poll loop for the substrate regardless (`lib/watcher.js`) —
807
- * it never ran through either substrate. The substrate never changes
808
- * `sandboxMode`'s honesty.
918
+ * Map a substrate error onto the peer's `FsError`, so the tool layer reads
919
+ * the same refusal dialect on both substrates. An agent that is out becomes
920
+ * an I/O error naming the cause — the share backend's "distro is gone" case
921
+ * is the same availability, but the message says which substrate failed.
922
+ *
923
+ * @param error - what the substrate raised.
924
+ * @returns the error to surface.
809
925
  */
810
- substrate: z.union(["share", "agent"]).default("agent"),
811
- });
812
-
813
- constructor(ctx, config) {
814
- // The share substrate is refused before any peer work: a profile still
815
- // carrying the retired value must fail loudly, with the migration, not
816
- // silently serve the one I/O path this provider exists to close.
817
- const refusal = shareSubstrateRefusal(config.substrate);
818
- if (refusal !== null) throw new Error(refusal.message);
819
- super(ctx, config);
820
- this.resolvedDistro = config.distro;
821
- /** Cached distro-user home, the base for relative input when `cwd` is empty. */
822
- this.resolvedHome = undefined;
823
- /** The deployment default mode — the capability fact the tool layer reads. */
824
- this.defaultMode = ctx.sandboxPolicy.defaultMode;
825
- /** The lazy agent-backed substrate, built on first use (see `agentSubstrate`). */
826
- this.agentSubstrateInstance = undefined;
827
- /** The distro-side bwrap usability probe, shared with the confined agents. */
828
- this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
829
- }
830
-
831
- /**
832
- * The mode this backend fences mutations with, or `undefined` when the
833
- * operator turned the fence off. This is the capability fact `dsh-tool-fs`
834
- * reads to advertise the escalation fields honestly, so it must disappear
835
- * together with the enforcement — never claim a boundary that is not applied.
836
- * @returns the deployment default mode, or `undefined` when unfenced.
837
- */
838
- get sandboxMode() {
839
- return this.config.sandbox ? this.defaultMode : undefined;
840
- }
841
-
842
- /**
843
- * The resident agent serving this filesystem's in-distro side work, created
844
- * on first use and kept for the provider's lifetime (its own idle timer
845
- * retires the process; a later call starts a fresh one).
846
- * @returns {Promise<WslAgent>} the agent for the pinned distro.
847
- */
848
- async publicationAgent() {
849
- if (this.agentInstance === undefined) {
850
- this.agentInstance = sharedAgent(await this.distro());
851
- }
852
- return this.agentInstance;
853
- }
854
-
855
- /**
856
- * The agent-backed substrate, built on first use over the same resident
857
- * agent every other distro side work shares.
858
- * @returns {Promise<AgentSubstrate>} the substrate.
859
- */
860
- async agentSubstrate() {
861
- if (this.agentSubstrateInstance === undefined) {
862
- this.agentSubstrateInstance = new AgentSubstrate({
863
- agent: await this.publicationAgent(),
864
- distro: await this.distro(),
865
- diffBasisMaxBytes: this.config.diffBasisMaxBytes,
866
- agentFor: (policy) => this.mutationAgent(policy),
867
- });
926
+ peerError(error) {
927
+ if (error instanceof FsCodedError) {
928
+ return new FsError(error.message, error.code, { cause: error });
929
+ }
930
+ if (error instanceof SandboxUnavailableError)
931
+ return new FsError(error.message, "FS_IO_ERROR", { cause: error });
932
+ const { message, code, cause } = substrateFailure(error);
933
+ return new FsError(message, code, cause === undefined ? {} : { cause });
868
934
  }
869
- return this.agentSubstrateInstance;
870
- }
871
-
872
- /**
873
- * The agent a MUTATION runs on under this resolved policy — stage-two
874
- * routing. A confined policy addresses the confined resident whose bwrap
875
- * profile binds exactly what the mode grants, so the kernel enforces what
876
- * the check decided; an escalated write (`danger-full-access`) or a disabled
877
- * sandbox addresses the plain resident, which is what those mean. A distro
878
- * without a usable bwrap refuses confined mutations instead of downgrading
879
- * them — the same closed failure the command path has.
880
- * @param {{mode: string, workspaceRoot: string}|undefined} policy - the
881
- * resolved file-effect policy, or undefined when the caller pre-checked.
882
- * @returns {Promise<WslAgent>} the agent to publish through.
883
- */
884
- async mutationAgent(policy) {
885
- if (!isConfinedMutation({ policy, sandboxEnabled: this.config.sandbox })) {
886
- return this.publicationAgent();
935
+ /**
936
+ * One resolved target's Linux path — the substrate's coordinate — falling
937
+ * back to the display spelling when the identity is not a distro share path.
938
+ *
939
+ * @param target - the resolved target.
940
+ * @returns the absolute Linux path.
941
+ */
942
+ linuxOf(target) {
943
+ return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
887
944
  }
888
- const distro = await this.distro();
889
- if (!(await this.sandbox.usable(distro))) {
890
- throw new SandboxUnavailableError(
891
- policy.mode,
892
- `bwrap is not usable inside distro "${distro}". Install it with ` +
893
- `"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
894
- `wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
895
- );
945
+ /**
946
+ * The pinned distro, resolving WSL's default on first use.
947
+ *
948
+ * @returns the distro name.
949
+ */
950
+ async distro() {
951
+ const config = this.config;
952
+ if (this.resolvedDistro.length === 0)
953
+ this.resolvedDistro = await defaultDistro({ wslPath: config.wslPath });
954
+ return this.resolvedDistro;
896
955
  }
897
- return confinedAgent({ distro, wslPath: this.config.wslPath, policy, maskWindowsDrive: this.config.maskWindowsDrive });
898
- }
899
-
900
- /**
901
- * Map a substrate error onto the peer's `FsError`, so the tool layer reads
902
- * the same refusal dialect on both substrates. An agent that is out becomes
903
- * an I/O error naming the cause — the share backend's "distro is gone" case
904
- * is the same availability, but the message says which substrate failed.
905
- * @param error - what the substrate raised.
906
- * @returns {FsError} the error to surface.
907
- */
908
- peerError(error) {
909
- if (error instanceof FsCodedError) return new FsError(error.message, error.code, { cause: error });
910
- if (error instanceof SandboxUnavailableError) return new FsError(error.message, "FS_IO_ERROR", { cause: error });
911
- const { message, code, cause } = substrateFailure(error);
912
- return new FsError(message, code, cause === undefined ? {} : { cause });
913
- }
914
-
915
- /**
916
- * One resolved target's Linux path — the substrate's coordinate — falling
917
- * back to the display spelling when the identity is not a distro share path.
918
- * @param target - the resolved target.
919
- * @returns the absolute Linux path.
920
- */
921
- linuxOf(target) {
922
- return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
923
- }
924
-
925
- /**
926
- * The pinned distro, resolving WSL's default on first use.
927
- * @returns the distro name.
928
- */
929
- async distro() {
930
- if (this.resolvedDistro.length === 0) this.resolvedDistro = await defaultDistro({ wslPath: this.config.wslPath });
931
- return this.resolvedDistro;
932
- }
933
-
934
- /**
935
- * Translate one caller path into the world path, enforcing the distro fence.
936
- * @param path - a Linux, UNC or Windows path.
937
- * @param cwd - the caller's override of the configured default workdir.
938
- * @returns the host path to operate on.
939
- */
940
- async worldPath(path, cwd) {
941
- const distro = await this.distro();
942
- let base = cwd ?? this.config.cwd;
943
- if (String(base ?? "").trim().length === 0 && isRelativeWorldPath(path)) {
944
- // An empty `cwd` means the distro user's home. Only relative input
945
- // consults the base at all, which is also the only case that has to pay
946
- // for the `wsl.exe` query — an absolute path never does.
947
- base = await distroHome(this);
956
+ /**
957
+ * Translate one caller path into the world path, enforcing the distro fence.
958
+ *
959
+ * @param path - a Linux, UNC or Windows path.
960
+ * @param cwd - the caller's override of the configured default workdir.
961
+ * @returns the host path to operate on.
962
+ */
963
+ async worldPath(path, cwd) {
964
+ const config = this.config;
965
+ const distro = await this.distro();
966
+ let base = cwd ?? config.cwd;
967
+ if (String(base ?? "").trim().length === 0 && isRelativeWorldPath(path)) {
968
+ // An empty `cwd` means the distro user's home. Only relative input
969
+ // consults the base at all, which is also the only case that has to pay
970
+ // for the `wsl.exe` query — an absolute path never does.
971
+ base = await distroHome(this);
972
+ }
973
+ const world = toWorldPath(path, { distro, cwd: base });
974
+ const refusal = outsideDistroRefusal({ path, world, distro, restrictToDistro: config.restrictToDistro });
975
+ if (refusal !== null) {
976
+ // Deliberately NOT `FS_SANDBOX_DENIED`. The file-tool layer turns that code
977
+ // into a denial marker plus an escalation offer, and no wider permission can
978
+ // lift this fence: it is a configuration choice, not a sandbox decision.
979
+ throw new FsError(refusal.message, refusal.code);
980
+ }
981
+ return world;
948
982
  }
949
- const world = toWorldPath(path, { distro, cwd: base });
950
- const refusal = outsideDistroRefusal({ path, world, distro, restrictToDistro: this.config.restrictToDistro });
951
- if (refusal !== null) {
952
- // Deliberately NOT `FS_SANDBOX_DENIED`. The file-tool layer turns that code
953
- // into a denial marker plus an escalation offer, and no wider permission can
954
- // lift this fence: it is a configuration choice, not a sandbox decision.
955
- throw new FsError(refusal.message, refusal.code);
983
+ /**
984
+ * Resolve a path to a target whose `targetKey` is the UNC identity the host
985
+ * I/O stack uses and whose `displayPath` is the Linux path the model sees.
986
+ *
987
+ * @param path - a Linux, UNC or Windows path.
988
+ * @param opts - optional `cwd` override and `signal`.
989
+ * @returns the resolved `FsTarget`.
990
+ */
991
+ async resolve(path, opts) {
992
+ const world = await this.worldPath(path, opts?.cwd);
993
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
994
+ if (linux === undefined)
995
+ throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
996
+ try {
997
+ // The substrate's resolved shape carries plain-string keys; the seam's
998
+ // branded tokens ride one type-level cast (runtime shape unchanged).
999
+ return (await (await this.agentSubstrate()).resolve(linux, opts?.signal));
1000
+ }
1001
+ catch (error) {
1002
+ throw this.peerError(error);
1003
+ }
956
1004
  }
957
- return world;
958
- }
959
-
960
- /**
961
- * Resolve a path to a target whose `targetKey` is the UNC identity the host
962
- * I/O stack uses and whose `displayPath` is the Linux path the model sees.
963
- * @param path - a Linux, UNC or Windows path.
964
- * @param opts - optional `cwd` override and `signal`.
965
- * @returns the resolved `FsTarget`.
966
- */
967
- async resolve(path, opts) {
968
- const world = await this.worldPath(path, opts?.cwd);
969
- const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
970
- if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
971
- try {
972
- return await (await this.agentSubstrate()).resolve(linux, opts?.signal);
973
- } catch (error) {
974
- throw this.peerError(error);
1005
+ /**
1006
+ * Enforce the per-call file-effect policy against one target and return the
1007
+ * EXACT target the mutation must use, so the checked identity is the mutated
1008
+ * one (no check-here-write-there window).
1009
+ *
1010
+ * `read-only` denies outright. `workspace-write` re-resolves the target NOW
1011
+ * (reflecting a symlink ancestor swapped since the tool resolved it), requires
1012
+ * containment under a writable root — the workspace plus the temp area of the
1013
+ * world the workspace lives in — and hands back that fresh target.
1014
+ * `danger-full-access` is the approved escalation and is not fenced. Refusal
1015
+ * is the structured `FS_SANDBOX_DENIED` the tool layer maps to its
1016
+ * model-facing `[sandbox: …]` marker and escalation hint.
1017
+ *
1018
+ * @param target - the resolved target to mutate.
1019
+ * @param sandboxPolicy - the per-call mode and workspace root; omit for the
1020
+ * deployment policy.
1021
+ * @returns the target the mutation must use.
1022
+ * @throws {FsError} with `FS_SANDBOX_DENIED` when the policy refuses it.
1023
+ */
1024
+ async checkedTarget(target, sandboxPolicy) {
1025
+ const config = this.config;
1026
+ if (!config.sandbox)
1027
+ return target;
1028
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1029
+ const modeRefusal = mutationModeRefusal(policy.mode, target.displayPath);
1030
+ if (modeRefusal !== null) {
1031
+ throw new FsError(modeRefusal.message, modeRefusal.code);
1032
+ }
1033
+ if (policy.mode !== "workspace-write")
1034
+ return target;
1035
+ const fresh = await this.resolve(target.displayPath);
1036
+ const distro = await this.distro();
1037
+ for (const root of wslWritableRoots(policy, distro)) {
1038
+ if (isWorldPathUnder(fresh.targetKey, root, { distro }))
1039
+ return fresh;
1040
+ }
1041
+ const denial = workspaceWriteDenial(target.displayPath);
1042
+ throw new FsError(denial.message, denial.code);
975
1043
  }
976
- }
977
-
978
- /**
979
- * Enforce the per-call file-effect policy against one target and return the
980
- * EXACT target the mutation must use, so the checked identity is the mutated
981
- * one (no check-here-write-there window).
982
- *
983
- * `read-only` denies outright. `workspace-write` re-resolves the target NOW
984
- * (reflecting a symlink ancestor swapped since the tool resolved it), requires
985
- * containment under a writable root — the workspace plus the temp area of the
986
- * world the workspace lives in — and hands back that fresh target.
987
- * `danger-full-access` is the approved escalation and is not fenced. Refusal
988
- * is the structured `FS_SANDBOX_DENIED` the tool layer maps to its
989
- * model-facing `[sandbox: …]` marker and escalation hint.
990
- *
991
- * @param target - the resolved target to mutate.
992
- * @param sandboxPolicy - the per-call mode and workspace root; omit for the
993
- * deployment policy.
994
- * @returns the target the mutation must use.
995
- * @throws FsError with `FS_SANDBOX_DENIED` when the policy refuses it.
996
- */
997
- async checkedTarget(target, sandboxPolicy) {
998
- if (!this.config.sandbox) return target;
999
- const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1000
- const modeRefusal = mutationModeRefusal(policy.mode, target.displayPath);
1001
- if (modeRefusal !== null) throw new FsError(modeRefusal.message, modeRefusal.code);
1002
- if (policy.mode !== "workspace-write") return target;
1003
- const fresh = await this.resolve(target.displayPath);
1004
- const distro = await this.distro();
1005
- for (const root of wslWritableRoots(policy, distro)) {
1006
- if (isWorldPathUnder(fresh.targetKey, root, { distro })) return fresh;
1044
+ /**
1045
+ * One target's metadata, answered from the distro.
1046
+ *
1047
+ * @param target - the resolved target.
1048
+ * @param signal - optional cancellation.
1049
+ * @returns the metadata, or undefined when absent.
1050
+ */
1051
+ async stat(target, signal) {
1052
+ try {
1053
+ return (await (await this.agentSubstrate()).stat(target, signal));
1054
+ }
1055
+ catch (error) {
1056
+ throw this.peerError(error);
1057
+ }
1007
1058
  }
1008
- const denial = workspaceWriteDenial(target.displayPath);
1009
- throw new FsError(denial.message, denial.code);
1010
- }
1011
-
1012
- async stat(target, signal) {
1013
- try {
1014
- return await (await this.agentSubstrate()).stat(target, signal);
1015
- } catch (error) {
1016
- throw this.peerError(error);
1059
+ /**
1060
+ * No-follow stat one path. The agent substrate answers from the distro,
1061
+ * where a symlink is a first-class entry.
1062
+ *
1063
+ * @param path - a Linux, UNC or Windows path.
1064
+ * @param opts - optional `cwd` override.
1065
+ * @param signal - optional cancellation.
1066
+ * @returns the path info, or undefined when absent.
1067
+ */
1068
+ async lstat(path, opts, signal) {
1069
+ const world = await this.worldPath(path, opts?.cwd);
1070
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1071
+ if (linux === undefined)
1072
+ throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1073
+ try {
1074
+ return (await (await this.agentSubstrate()).lstat(linux, signal));
1075
+ }
1076
+ catch (error) {
1077
+ throw this.peerError(error);
1078
+ }
1017
1079
  }
1018
- }
1019
-
1020
- /**
1021
- * No-follow stat one path. The agent substrate answers from the distro,
1022
- * where a symlink is a first-class entry.
1023
- * @param path - a Linux, UNC or Windows path.
1024
- * @param opts - optional `cwd` override.
1025
- * @param signal - optional cancellation.
1026
- * @returns the path info, or undefined when absent.
1027
- */
1028
- async lstat(path, opts, signal) {
1029
- const world = await this.worldPath(path, opts?.cwd);
1030
- const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1031
- if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1032
- try {
1033
- return await (await this.agentSubstrate()).lstat(linux, signal);
1034
- } catch (error) {
1035
- throw this.peerError(error);
1080
+ /**
1081
+ * Read one whole regular UTF-8 file from the distro.
1082
+ *
1083
+ * @param target - the resolved target.
1084
+ * @param signal - optional cancellation.
1085
+ * @returns the file text.
1086
+ */
1087
+ async readText(target, signal) {
1088
+ try {
1089
+ return await (await this.agentSubstrate()).readText(target, signal);
1090
+ }
1091
+ catch (error) {
1092
+ throw this.peerError(error);
1093
+ }
1036
1094
  }
1037
- }
1038
-
1039
- async readText(target, signal) {
1040
- try {
1041
- return await (await this.agentSubstrate()).readText(target, signal);
1042
- } catch (error) {
1043
- throw this.peerError(error);
1095
+ /**
1096
+ * Stream one whole regular UTF-8 file from the distro.
1097
+ *
1098
+ * @param target - the resolved target.
1099
+ * @param signal - optional cancellation.
1100
+ * @returns the text chunks.
1101
+ */
1102
+ async streamText(target, signal) {
1103
+ try {
1104
+ return await (await this.agentSubstrate()).streamText(target, signal);
1105
+ }
1106
+ catch (error) {
1107
+ throw this.peerError(error);
1108
+ }
1044
1109
  }
1045
- }
1046
-
1047
- async streamText(target, signal) {
1048
- try {
1049
- return await (await this.agentSubstrate()).streamText(target, signal);
1050
- } catch (error) {
1051
- throw this.peerError(error);
1110
+ /**
1111
+ * Read one whole regular file as raw bytes, bounded.
1112
+ *
1113
+ * @param target - the resolved target.
1114
+ * @param signal - optional cancellation.
1115
+ * @param maxBytes - inclusive byte cap on the content.
1116
+ * @returns the bytes.
1117
+ */
1118
+ async readBytes(target, signal, maxBytes) {
1119
+ try {
1120
+ return await (await this.agentSubstrate()).readBytes(target, signal, maxBytes);
1121
+ }
1122
+ catch (error) {
1123
+ throw this.peerError(error);
1124
+ }
1052
1125
  }
1053
- }
1054
-
1055
- async readBytes(target, signal, maxBytes) {
1056
- try {
1057
- return await (await this.agentSubstrate()).readBytes(target, signal, maxBytes);
1058
- } catch (error) {
1059
- throw this.peerError(error);
1126
+ /**
1127
+ * Read one byte window of a regular file.
1128
+ *
1129
+ * @param target - the resolved target.
1130
+ * @param range - the window.
1131
+ * @param signal - optional cancellation.
1132
+ * @returns the window's bytes.
1133
+ */
1134
+ async readByteRange(target, range, signal) {
1135
+ try {
1136
+ return await (await this.agentSubstrate()).readByteRange(target, range, signal);
1137
+ }
1138
+ catch (error) {
1139
+ throw this.peerError(error);
1140
+ }
1060
1141
  }
1061
- }
1062
-
1063
- async readByteRange(target, range, signal) {
1064
- try {
1065
- return await (await this.agentSubstrate()).readByteRange(target, range, signal);
1066
- } catch (error) {
1067
- throw this.peerError(error);
1142
+ /**
1143
+ * List one directory level. The substrate's rows already carry Linux display
1144
+ * paths and canonical identities — nothing to rewrite.
1145
+ *
1146
+ * @param target - the resolved directory.
1147
+ * @param signal - optional cancellation.
1148
+ * @returns the directory rows.
1149
+ */
1150
+ async listDir(target, signal) {
1151
+ try {
1152
+ return (await (await this.agentSubstrate()).listDir(target, signal));
1153
+ }
1154
+ catch (error) {
1155
+ throw this.peerError(error);
1156
+ }
1068
1157
  }
1069
- }
1070
-
1071
- /**
1072
- * List one directory level. The substrate's rows already carry Linux display
1073
- * paths and canonical identities — nothing to rewrite.
1074
- * @param target - the resolved directory.
1075
- * @param signal - optional cancellation.
1076
- * @returns the directory rows.
1077
- */
1078
- async listDir(target, signal) {
1079
- try {
1080
- return await (await this.agentSubstrate()).listDir(target, signal);
1081
- } catch (error) {
1082
- throw this.peerError(error);
1158
+ /**
1159
+ * Map an absolute host path into this execution world, SYNCHRONOUSLY — the
1160
+ * upstream contract (`FileSystem.processPathFromHostPath`) and what its
1161
+ * consumers rely on: `packages/spill/spill-policy` passes this method as a
1162
+ * plain callback over image attachments, and `packages/api/session-controller`
1163
+ * plus the UI's open/reveal path compare the return value against `undefined`
1164
+ * without awaiting. The mapping is therefore built from facts that are already
1165
+ * known — the distro, once `distro()` has resolved it (the pinned config, or
1166
+ * the default `wsl.exe` answered earlier: the first filesystem operation
1167
+ * resolves it), and the configured `cwd`, or the resolved home for a relative
1168
+ * path. A call that would have to ask `wsl.exe` — a cold default distro, or a
1169
+ * relative path before the home is known — answers `undefined`, which every
1170
+ * consumer reads as "no mapping", the same answer the ssh provider gives for
1171
+ * a path it cannot map.
1172
+ *
1173
+ * The VALUE is the world spelling the backend keys on (the UNC identity for a
1174
+ * distro path), so the consumers' round trip — map, `resolve`, `processPath`,
1175
+ * compare with the original host path — closes exactly as it does for the
1176
+ * host backend. Real operations are unaffected: they take the async
1177
+ * {@link WslFileSystem.worldPath} and its loud refusals.
1178
+ *
1179
+ * @param hostPath - an absolute host path (a relative one maps only when the
1180
+ * configured `cwd` or a known home gives it a base).
1181
+ * @returns the world path for the same file, or undefined when it cannot be
1182
+ * mapped with what is already known.
1183
+ */
1184
+ processPathFromHostPath(hostPath) {
1185
+ const config = this.config;
1186
+ if (this.resolvedDistro.length === 0)
1187
+ return undefined;
1188
+ let base = config.cwd;
1189
+ if (String(base ?? "").trim().length === 0 && isRelativeWorldPath(hostPath)) {
1190
+ if (this.resolvedHome === undefined)
1191
+ return undefined;
1192
+ base = this.resolvedHome;
1193
+ }
1194
+ const world = toWorldPath(hostPath, { distro: this.resolvedDistro, cwd: base });
1195
+ const refusal = outsideDistroRefusal({ path: hostPath, world, distro: this.resolvedDistro, restrictToDistro: config.restrictToDistro });
1196
+ return refusal === null ? world : undefined;
1083
1197
  }
1084
- }
1085
-
1086
- /**
1087
- * Map an absolute host path into this execution world. POSIX input is a Linux
1088
- * path inside the distro; anything else keeps the host backend's behaviour.
1089
- * @param hostPath - an absolute path.
1090
- * @returns the process path for the same file, or undefined when unmappable.
1091
- */
1092
- async processPathFromHostPath(hostPath) {
1093
- return this.worldPath(hostPath);
1094
- }
1095
-
1096
- /**
1097
- * Watch from inside the distro, where the kernel can actually report change:
1098
- * a long-lived `wsl.exe` poll loop (`lib/watcher.js`) fires the seam's
1099
- * invalidation callback. The seam asks for coarse invalidation, not events,
1100
- * so a poll is honest — and creations and deletions are both seen, because
1101
- * they bump the parent directory's mtime. Replaces the previous flat
1102
- * refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
1103
- *
1104
- * A target that disappears mid-watch ends the loop with a report through
1105
- * `changed` — never the armed-but-never-fires silence a missing directory
1106
- * would otherwise produce. The scan's depth is `watchMaxDepth`.
1107
- *
1108
- * @param target - the resolved file or directory to observe.
1109
- * @param changed - the invalidation callback.
1110
- * @param signal - cancels initialization; the caller closes an armed watcher.
1111
- * @returns resolves once active, with the async close function.
1112
- */
1113
- async watch(target, changed, signal) {
1114
- signal.throwIfAborted();
1115
- const parsed = uncToPosix(target.targetKey);
1116
- if (parsed === undefined) {
1117
- throw new FsError(`cannot watch "${target.displayPath}": not a path inside the distro`, "FS_IO_ERROR");
1198
+ /**
1199
+ * Watch from inside the distro, where the kernel can actually report change:
1200
+ * a long-lived `wsl.exe` poll loop (`lib/watcher.js`) fires the seam's
1201
+ * invalidation callback. The seam asks for coarse invalidation, not events,
1202
+ * so a poll is honest — and creations and deletions are both seen, because
1203
+ * they bump the parent directory's mtime. Replaces the previous flat
1204
+ * refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
1205
+ *
1206
+ * A target that disappears mid-watch ends the loop with a report through
1207
+ * `changed` — never the armed-but-never-fires silence a missing directory
1208
+ * would otherwise produce. The scan's depth is `watchMaxDepth`.
1209
+ *
1210
+ * @param target - the resolved file or directory to observe.
1211
+ * @param changed - the invalidation callback.
1212
+ * @param signal - cancels initialization; the caller closes an armed watcher.
1213
+ * @returns resolves once active, with the async close function.
1214
+ */
1215
+ async watch(target, changed, signal) {
1216
+ signal.throwIfAborted();
1217
+ const config = this.config;
1218
+ const parsed = uncToPosix(target.targetKey);
1219
+ if (parsed === undefined) {
1220
+ throw new FsError(`cannot watch "${target.displayPath}": not a path inside the distro`, "FS_IO_ERROR");
1221
+ }
1222
+ const distro = await this.distro();
1223
+ return armDistroWatcher({
1224
+ wslPath: config.wslPath,
1225
+ distro,
1226
+ linuxPath: parsed.linuxPath,
1227
+ onChange: changed,
1228
+ onError: (error) => changed(error),
1229
+ signal,
1230
+ maxDepth: config.watchMaxDepth,
1231
+ });
1118
1232
  }
1119
- const distro = await this.distro();
1120
- return armDistroWatcher({
1121
- wslPath: this.config.wslPath,
1122
- distro,
1123
- linuxPath: parsed.linuxPath,
1124
- onChange: changed,
1125
- onError: (error) => changed(error),
1126
- signal,
1127
- maxDepth: this.config.watchMaxDepth,
1128
- });
1129
- }
1130
-
1131
- /**
1132
- * Fence the write by the per-call policy, then publish through the
1133
- * substrate. The fence's fresh target is the one checked and written, so a
1134
- * refusal costs no I/O and cannot be raced by a swapped symlink; the guard
1135
- * rides the write op, where the agent re-verifies it one syscall before the
1136
- * rename and the confined resident enforces it in the kernel.
1137
- *
1138
- * @param target - the resolved target.
1139
- * @param content - the full new file content.
1140
- * @param expected - the write intent.
1141
- * @param signal - optional cancellation.
1142
- * @param sandboxPolicy - the per-call mode and workspace root; omit for the
1143
- * deployment policy.
1144
- * @returns the write outcome.
1145
- */
1146
- async writeText(target, content, expected, signal, sandboxPolicy) {
1147
- const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1148
- const fenced = await this.checkedTarget(target, policy);
1149
- try {
1150
- return await (await this.agentSubstrate()).writeText(fenced, content, expected, signal, policy);
1151
- } catch (error) {
1152
- throw this.peerError(error);
1233
+ /**
1234
+ * Fence the write by the per-call policy, then publish through the
1235
+ * substrate. The fence's fresh target is the one checked and written, so a
1236
+ * refusal costs no I/O and cannot be raced by a swapped symlink; the guard
1237
+ * rides the write op, where the agent re-verifies it one syscall before the
1238
+ * rename and the confined resident enforces it in the kernel.
1239
+ *
1240
+ * @param target - the resolved target.
1241
+ * @param content - the full new file content.
1242
+ * @param expected - the write intent.
1243
+ * @param signal - optional cancellation.
1244
+ * @param sandboxPolicy - the per-call mode and workspace root; omit for the
1245
+ * deployment policy.
1246
+ * @returns the write outcome.
1247
+ */
1248
+ async writeText(target, content, expected, signal, sandboxPolicy) {
1249
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1250
+ const fenced = await this.checkedTarget(target, policy);
1251
+ try {
1252
+ return (await (await this.agentSubstrate()).writeText(fenced, content, expected, signal, policy));
1253
+ }
1254
+ catch (error) {
1255
+ throw this.peerError(error);
1256
+ }
1153
1257
  }
1154
- }
1155
-
1156
- /**
1157
- * Fence the edit by the same policy, keeping the guard's observable
1158
- * behaviour.
1159
- *
1160
- * @param target - the resolved target.
1161
- * @param edit - the literal search/replace request.
1162
- * @param expected - the version guard.
1163
- * @param signal - optional cancellation.
1164
- * @param sandboxPolicy - the per-call mode and workspace root; omit for the
1165
- * deployment policy.
1166
- * @returns the edit outcome.
1167
- */
1168
- async editText(target, edit, expected, signal, sandboxPolicy) {
1169
- const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1170
- const fenced = await this.checkedTarget(target, policy);
1171
- try {
1172
- return await (await this.agentSubstrate()).editText(fenced, edit, expected, signal, policy);
1173
- } catch (error) {
1174
- throw this.peerError(error);
1258
+ /**
1259
+ * Fence the edit by the same policy, keeping the guard's observable
1260
+ * behaviour.
1261
+ *
1262
+ * @param target - the resolved target.
1263
+ * @param edit - the literal search/replace request.
1264
+ * @param expected - the version guard.
1265
+ * @param signal - optional cancellation.
1266
+ * @param sandboxPolicy - the per-call mode and workspace root; omit for the
1267
+ * deployment policy.
1268
+ * @returns the edit outcome.
1269
+ */
1270
+ async editText(target, edit, expected, signal, sandboxPolicy) {
1271
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1272
+ const fenced = await this.checkedTarget(target, policy);
1273
+ try {
1274
+ return (await (await this.agentSubstrate()).editText(fenced, edit, expected, signal, policy));
1275
+ }
1276
+ catch (error) {
1277
+ throw this.peerError(error);
1278
+ }
1175
1279
  }
1176
- }
1177
1280
  }