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.
- package/CHANGELOG.md +345 -8
- package/CONTRIBUTING.md +221 -68
- package/README.md +50 -35
- package/SECURITY.md +18 -2
- package/SUPPORT.md +23 -7
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +200 -57
- package/docs/CONFIGURATION.md +180 -16
- package/docs/LIMITATIONS.md +91 -33
- package/docs/PARITY.md +66 -38
- package/docs/PEER-PARITY.md +95 -32
- package/docs/README.zh.md +49 -31
- package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
- package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
- package/lib/agent-confined.d.ts +42 -0
- package/lib/agent-confined.js +25 -41
- package/lib/agent-errors.d.ts +31 -0
- package/lib/agent-errors.js +19 -16
- package/lib/agent-exec.d.ts +159 -0
- package/lib/agent-exec.js +151 -165
- package/lib/agent-protocol.d.ts +192 -0
- package/lib/agent-protocol.js +76 -100
- package/lib/agent-shared.d.ts +27 -0
- package/lib/agent-shared.js +12 -12
- package/lib/agent.d.ts +353 -0
- package/lib/agent.js +536 -552
- package/lib/auto-preset.d.ts +110 -0
- package/lib/auto-preset.js +157 -141
- package/lib/bwrap.d.ts +90 -0
- package/lib/bwrap.js +18 -18
- package/lib/file-reference-wsl.d.ts +160 -0
- package/lib/file-reference-wsl.js +192 -173
- package/lib/fs-decisions.d.ts +107 -0
- package/lib/fs-decisions.js +64 -60
- package/lib/fs-routing.d.ts +182 -0
- package/lib/fs-routing.js +173 -116
- package/lib/fs-substrate.d.ts +258 -0
- package/lib/fs-substrate.js +248 -248
- package/lib/fs.d.ts +10 -0
- package/lib/fs.js +5 -0
- package/lib/fsio-agent.d.ts +307 -0
- package/lib/fsio-agent.js +414 -410
- package/lib/fsio-text.d.ts +115 -0
- package/lib/fsio-text.js +104 -84
- package/lib/git-route.d.ts +100 -0
- package/lib/git-route.js +46 -47
- package/lib/index.d.ts +1003 -0
- package/lib/index.js +1106 -1003
- package/lib/listing.d.ts +114 -0
- package/lib/listing.js +68 -55
- package/lib/paths.d.ts +182 -0
- package/lib/paths.js +134 -109
- package/lib/picker.d.ts +266 -0
- package/lib/picker.js +371 -351
- package/lib/ports.d.ts +44 -0
- package/lib/ports.js +39 -29
- package/lib/preflight.d.ts +91 -0
- package/lib/preflight.js +94 -0
- package/lib/preset-choice.d.ts +33 -0
- package/lib/preset-choice.js +15 -7
- package/lib/probe-cache.d.ts +51 -0
- package/lib/probe-cache.js +43 -29
- package/lib/sandbox-core.d.ts +149 -0
- package/lib/sandbox-core.js +239 -87
- package/lib/sandbox.d.ts +48 -0
- package/lib/sandbox.js +4 -4
- package/lib/search-exec.d.ts +96 -0
- package/lib/search-exec.js +52 -79
- package/lib/search-route.d.ts +75 -0
- package/lib/search-route.js +30 -31
- package/lib/shell-env.d.ts +88 -0
- package/lib/shell-env.js +78 -74
- package/lib/shell.d.ts +10 -0
- package/lib/shell.js +5 -0
- package/lib/subprocess.d.ts +313 -0
- package/lib/subprocess.js +267 -262
- package/lib/terminal-activity.d.ts +98 -0
- package/lib/terminal-activity.js +29 -39
- package/lib/terminal-route.d.ts +27 -0
- package/lib/terminal-route.js +16 -8
- package/lib/watcher.d.ts +77 -0
- package/lib/watcher.js +120 -126
- package/lib/workspace-files-route.d.ts +132 -0
- package/lib/workspace-files-route.js +73 -81
- package/lib/workspace-files-wsl.d.ts +257 -0
- package/lib/workspace-files-wsl.js +311 -251
- package/lib/wsl.d.ts +266 -0
- package/lib/wsl.js +150 -155
- package/locale/preflight/en.json +6 -0
- package/locale/preflight/zh.json +6 -0
- package/package.json +78 -22
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
*
|
|
157
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
|
|
238
|
+
get sandboxMode() {
|
|
239
|
+
return this.mode;
|
|
240
|
+
}
|
|
169
241
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
*
|
|
755
|
-
*
|
|
756
|
-
* inside the distro.
|
|
825
|
+
* @param ctx - the composition context.
|
|
826
|
+
* @param config - the validated plugin config.
|
|
757
827
|
*/
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
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
|
-
|
|
851
|
+
get sandboxMode() {
|
|
852
|
+
const config = this.config;
|
|
853
|
+
return config.sandbox ? this.defaultMode : undefined;
|
|
854
|
+
}
|
|
771
855
|
/**
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
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
|
-
|
|
862
|
+
async publicationAgent() {
|
|
863
|
+
if (this.agentInstance === undefined) {
|
|
864
|
+
this.agentInstance = sharedAgent(await this.distro());
|
|
865
|
+
}
|
|
866
|
+
return this.agentInstance;
|
|
867
|
+
}
|
|
779
868
|
/**
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
}
|