dsh-plugin-wsl-env 0.1.10 → 0.3.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 +268 -0
- package/README.md +21 -2
- package/agent/wsl-agent.sh +498 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +12 -1
- package/docs/LIMITATIONS.md +70 -5
- package/docs/PARITY.md +70 -0
- package/docs/PEER-PARITY.md +60 -0
- package/docs/README.zh.md +31 -20
- package/docs/UPSTREAM-FSIO-EXPORT.md +87 -0
- package/lib/agent-confined.js +81 -0
- package/lib/agent-errors.js +33 -0
- package/lib/agent-exec.js +204 -0
- package/lib/agent-protocol.js +174 -0
- package/lib/agent-shared.js +32 -0
- package/lib/agent.js +564 -0
- package/lib/bwrap.js +74 -0
- package/lib/fs-publish.js +42 -0
- package/lib/fs-substrate.js +285 -0
- package/lib/fsio-agent.js +481 -0
- package/lib/fsio-text.js +153 -0
- package/lib/index.js +430 -12
- package/lib/ports.js +61 -0
- package/lib/sandbox.js +35 -28
- package/lib/shell-env.js +28 -2
- package/lib/subprocess.js +15 -0
- package/lib/terminal-route.js +31 -0
- package/lib/watcher.js +127 -0
- package/package.json +13 -4
package/lib/agent.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of the resident in-distro agent.
|
|
3
|
+
*
|
|
4
|
+
* One {@link WslAgent} instance owns one long-lived `wsl.exe` process per
|
|
5
|
+
* distro. The lifetime rules, in order of importance:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Correctness before speed.** Any agent failure is answered by at most
|
|
8
|
+
* one rebuild; the requests that were in flight ride it ONLY when their
|
|
9
|
+
* first execution provably changed nothing (the read-only FS ops). A
|
|
10
|
+
* command or a write that was in flight has UNKNOWN state — replaying it
|
|
11
|
+
* would run non-idempotent work twice or report a create that actually
|
|
12
|
+
* succeeded as a refusal — so it fails instead, and the caller re-reads
|
|
13
|
+
* before retrying. A second failure marks the agent permanently dead and
|
|
14
|
+
* every future call rejects with {@link AgentUnavailableError} — the
|
|
15
|
+
* executor's cue to fall back to the one-shot `wsl.exe` path it shipped
|
|
16
|
+
* with, so the worst case of the agent is "today's behaviour, plus one
|
|
17
|
+
* failed round trip".
|
|
18
|
+
* 2. **The in-distro timeout is the real one.** The EXEC frame carries the
|
|
19
|
+
* timeout in whole seconds and the agent SIGTERMs the child itself, so a
|
|
20
|
+
* timed-out command dies where it lives instead of leaving an orphan
|
|
21
|
+
* behind a dead `wsl.exe`. The host-side watchdog is armed on the agent's
|
|
22
|
+
* `ACK` — the dequeue signal — so it measures EXECUTION, never the queue
|
|
23
|
+
* wait: a short-budget request queued behind a long one can no longer
|
|
24
|
+
* kill the shared transport for a stall that was never its own. It still
|
|
25
|
+
* catches an agent that accepted a request and then stopped answering at
|
|
26
|
+
* all.
|
|
27
|
+
* 3. **Idle agents go away.** `idleMs` after the last exchange the process is
|
|
28
|
+
* killed; the next call starts a fresh one. WSL VMs are shared, but every
|
|
29
|
+
* process is a promise someone has to keep.
|
|
30
|
+
*
|
|
31
|
+
* The transport is injectable so the lifecycle and the fallback decision are
|
|
32
|
+
* unit-testable without a distro.
|
|
33
|
+
*
|
|
34
|
+
* @module dsh-plugin-wsl/agent
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { spawn } from "node:child_process";
|
|
38
|
+
import { AgentUnavailableError, CwdError } from "./agent-errors.js";
|
|
39
|
+
import { AGENT_NAME, MAX_FRAME_BYTES, PROTOCOL_VERSION, encodeExecFrame, encodeFsFrame, encodeKill, encodeSetEnv, parseAgentLine } from "./agent-protocol.js";
|
|
40
|
+
|
|
41
|
+
/** How long a rebuilt agent has to re-answer the handshake before giving up. */
|
|
42
|
+
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
43
|
+
|
|
44
|
+
/** Extra time the host watchdog waits past the request's own in-distro timeout. */
|
|
45
|
+
const WATCHDOG_GRACE_MS = 13000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} options - the spawn options.
|
|
49
|
+
* @param {string} options.wslPath - the `wsl.exe` path.
|
|
50
|
+
* @param {string} options.distro - the distro name.
|
|
51
|
+
* @param {string} options.scriptPath - the agent script as a Linux path inside
|
|
52
|
+
* the distro (a `/mnt/<drive>/...` translation of the package's
|
|
53
|
+
* `agent/wsl-agent.sh`).
|
|
54
|
+
* @returns {import("node:child_process").ChildProcess} the spawned process.
|
|
55
|
+
*/
|
|
56
|
+
function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [] }) {
|
|
57
|
+
return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
|
|
58
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The resident agent for one distro.
|
|
65
|
+
*/
|
|
66
|
+
export class WslAgent {
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} config - the agent configuration.
|
|
69
|
+
* @param {string} config.distro - the distro this agent serves.
|
|
70
|
+
* @param {string} config.scriptPath - the agent script as a Linux path
|
|
71
|
+
* inside the distro (see {@link spawnDefault}).
|
|
72
|
+
* @param {string} [config.wslPath] - the `wsl.exe` path.
|
|
73
|
+
* @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
|
|
74
|
+
* @param {string[]} [config.argvPrefix] - arguments inserted between
|
|
75
|
+
* `wsl.exe --exec` and the `sh script` pair — the confinement wrapper
|
|
76
|
+
* (`bwrap … --`) a confined agent runs its whole lifetime under, so every
|
|
77
|
+
* filesystem op it performs is kernel-enforced. Empty for the plain agent.
|
|
78
|
+
* @param {(options: {wslPath: string, distro: string, scriptPath: string, argvPrefix: string[]}) => import("node:child_process").ChildProcess} [config.spawnTransport]
|
|
79
|
+
* injectable process factory for tests.
|
|
80
|
+
*/
|
|
81
|
+
constructor(config) {
|
|
82
|
+
this.distro = config.distro;
|
|
83
|
+
this.scriptPath = config.scriptPath;
|
|
84
|
+
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
85
|
+
this.argvPrefix = config.argvPrefix ?? [];
|
|
86
|
+
this.idleMs = config.idleMs ?? 600000;
|
|
87
|
+
/** Hard ceiling on one stdout protocol line; a longer line is a broken or
|
|
88
|
+
* hostile agent, not data to hold — readline used to accumulate it all. */
|
|
89
|
+
this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
|
|
90
|
+
this.configuredSpawn = config.spawnTransport;
|
|
91
|
+
/** @type {import("node:child_process").ChildProcess|null} */
|
|
92
|
+
this.process = null;
|
|
93
|
+
/** @type {"idle"|"starting"|"ready"|"dead"} */
|
|
94
|
+
this.state = "idle";
|
|
95
|
+
/** @type {Promise<void>|null} */
|
|
96
|
+
this.startPromise = null;
|
|
97
|
+
this.nextRequestId = 0;
|
|
98
|
+
/** @type {Map<string, {lines: string[], resolve: Function, reject: Function, watchdog: NodeJS.Timeout|null, signal: AbortSignal|null, onAbort: Function|null}>} */
|
|
99
|
+
this.pending = new Map();
|
|
100
|
+
this.rebuildUsed = false;
|
|
101
|
+
/** @type {NodeJS.Timeout|null} */
|
|
102
|
+
this.idleTimer = null;
|
|
103
|
+
this.lastError = "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether the agent is permanently out — the executor's fallback cue.
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
get unavailable() {
|
|
111
|
+
return this.state === "dead";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Human-readable reason for the last transition to `dead`, for logging. */
|
|
115
|
+
get unavailableReason() {
|
|
116
|
+
return this.lastError;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Start the process and wait for the handshake. Re-entrant: concurrent
|
|
121
|
+
* callers share the one start.
|
|
122
|
+
* @returns {Promise<void>} resolved once the state is `ready`.
|
|
123
|
+
*/
|
|
124
|
+
start() {
|
|
125
|
+
if (this.state === "dead") {
|
|
126
|
+
return Promise.reject(new AgentUnavailableError(this.lastError));
|
|
127
|
+
}
|
|
128
|
+
if (this.state === "ready") return Promise.resolve();
|
|
129
|
+
if (this.startPromise) return this.startPromise;
|
|
130
|
+
this.state = "starting";
|
|
131
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
132
|
+
const child = (this.process = (this.configuredSpawn ?? spawnDefault)({
|
|
133
|
+
wslPath: this.wslPath,
|
|
134
|
+
distro: this.distro,
|
|
135
|
+
scriptPath: this.scriptPath,
|
|
136
|
+
argvPrefix: this.argvPrefix,
|
|
137
|
+
}));
|
|
138
|
+
const handshakeTimer = setTimeout(() => {
|
|
139
|
+
this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
|
|
140
|
+
child.kill();
|
|
141
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
142
|
+
const settleStart = (error) => {
|
|
143
|
+
clearTimeout(handshakeTimer);
|
|
144
|
+
this.startPromise = null;
|
|
145
|
+
if (error) {
|
|
146
|
+
this.state = this.rebuildUsed ? "dead" : "idle";
|
|
147
|
+
if (this.rebuildUsed) this.lastError = this.lastError || String(error);
|
|
148
|
+
reject(error instanceof AgentUnavailableError ? error : new AgentUnavailableError(String(error)));
|
|
149
|
+
} else {
|
|
150
|
+
this.state = "ready";
|
|
151
|
+
resolve();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
child.on("error", (error) => {
|
|
155
|
+
this.lastError = String(error);
|
|
156
|
+
settleStart(error);
|
|
157
|
+
});
|
|
158
|
+
child.on("exit", (code, signalName) => {
|
|
159
|
+
if (this.state === "starting") {
|
|
160
|
+
this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})`;
|
|
161
|
+
settleStart(new Error(this.lastError));
|
|
162
|
+
}
|
|
163
|
+
this.handleProcessExit(code, signalName);
|
|
164
|
+
});
|
|
165
|
+
// A bounded line splitter instead of readline: readline accumulates an
|
|
166
|
+
// over-long line whole before anyone can object, which is exactly the
|
|
167
|
+
// unbounded-capture hole. Protocol lines are ASCII by construction
|
|
168
|
+
// (ids, numbers, base64), so chunk-boundary decoding is not a concern.
|
|
169
|
+
let buffered = "";
|
|
170
|
+
child.stdout.on("data", (chunk) => {
|
|
171
|
+
buffered += chunk.toString("utf8");
|
|
172
|
+
for (;;) {
|
|
173
|
+
const newline = buffered.indexOf("\n");
|
|
174
|
+
if (newline === -1) {
|
|
175
|
+
if (buffered.length > this.frameCapBytes) {
|
|
176
|
+
this.lastError = `agent frame of ${buffered.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
|
|
177
|
+
buffered = "";
|
|
178
|
+
this.process?.kill();
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const line = buffered.slice(0, newline);
|
|
183
|
+
buffered = buffered.slice(newline + 1);
|
|
184
|
+
if (line.length > this.frameCapBytes) {
|
|
185
|
+
this.lastError = `agent frame of ${line.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
|
|
186
|
+
buffered = "";
|
|
187
|
+
this.process?.kill();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.handleLine(line, settleStart, handshakeTimer);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
child.stderr?.on("data", (chunk) => {
|
|
194
|
+
// Diagnostics only; `wsl.exe` and the shell chatter here on failure.
|
|
195
|
+
this.lastError = String(chunk).trim().slice(0, 2000) || this.lastError;
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
return this.startPromise;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One stdout line from the agent. The first must be a matching HELLO.
|
|
203
|
+
* @private
|
|
204
|
+
*/
|
|
205
|
+
handleLine(line, settleStart, handshakeTimer) {
|
|
206
|
+
const message = parseAgentLine(line);
|
|
207
|
+
switch (message.type) {
|
|
208
|
+
case "hello": {
|
|
209
|
+
if (message.name !== AGENT_NAME || message.version !== PROTOCOL_VERSION) {
|
|
210
|
+
this.lastError = `agent handshake mismatch: got ${message.name}@${message.version}, want ${AGENT_NAME}@${PROTOCOL_VERSION}`;
|
|
211
|
+
this.process?.kill();
|
|
212
|
+
settleStart(new Error(this.lastError));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
clearTimeout(handshakeTimer);
|
|
216
|
+
settleStart();
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "ack": {
|
|
220
|
+
// Dequeue signal: start this request's own budget clock NOW. Armed
|
|
221
|
+
// here instead of at submission, a queued request can no longer time
|
|
222
|
+
// out on a queue it never chose to join — and its watchdog can never
|
|
223
|
+
// kill the shared transport for a stall that was never its own.
|
|
224
|
+
const pending = this.pending.get(message.id);
|
|
225
|
+
pending?.armWatchdog?.();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case "result": {
|
|
229
|
+
const pending = this.pending.get(message.id);
|
|
230
|
+
if (!pending) break; // answered after its caller gave up — noise
|
|
231
|
+
this.clearPending(pending);
|
|
232
|
+
this.pending.delete(message.id);
|
|
233
|
+
this.rearmIdleTimer();
|
|
234
|
+
pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr, truncated: message.truncated });
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case "agentError": {
|
|
238
|
+
const pending = this.pending.get(message.id);
|
|
239
|
+
if (!pending) break;
|
|
240
|
+
this.clearPending(pending);
|
|
241
|
+
this.pending.delete(message.id);
|
|
242
|
+
this.rearmIdleTimer();
|
|
243
|
+
// A cwd rejection is the command's own failure, not the agent's — it
|
|
244
|
+
// becomes a CwdError so the execution layer can report it exactly like
|
|
245
|
+
// the one-shot path's relay text.
|
|
246
|
+
if (message.reason === "cwd") {
|
|
247
|
+
pending.reject(new CwdError(message.message));
|
|
248
|
+
} else {
|
|
249
|
+
pending.reject(new AgentUnavailableError(`agent rejected request ${message.id} (${message.reason}): ${message.message}`));
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
default:
|
|
254
|
+
// Garbage after the handshake is logged and dropped: a late RES for an
|
|
255
|
+
// abandoned request is expected noise, not a broken pipe.
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The process died. Requests in flight ride exactly one rebuild; a second
|
|
262
|
+
* death is permanent.
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
handleProcessExit(code, signalName) {
|
|
266
|
+
this.process = null;
|
|
267
|
+
if (this.pending.size === 0) {
|
|
268
|
+
if (this.state !== "dead") this.state = "idle";
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!this.rebuildUsed) {
|
|
272
|
+
this.rebuildUsed = true;
|
|
273
|
+
this.lastError = `agent exited unexpectedly (code=${code} signal=${signalName ?? "none"}); rebuilding once`;
|
|
274
|
+
this.state = "idle";
|
|
275
|
+
this.startPromise = null;
|
|
276
|
+
const carrying = [...this.pending.values()];
|
|
277
|
+
this.pending.clear();
|
|
278
|
+
// Only provably read-only FS requests ride the rebuild: whatever their
|
|
279
|
+
// first execution did, it changed nothing. A command or a write that was
|
|
280
|
+
// in flight has UNKNOWN state — replaying it would run non-idempotent
|
|
281
|
+
// work a second time, or report a guarded create that actually
|
|
282
|
+
// succeeded as a refusal — so it fails with that fact instead. A stale
|
|
283
|
+
// watchdog must not survive either: its closure kills `this.process`,
|
|
284
|
+
// which after the rebuild is the NEW transport.
|
|
285
|
+
const relayable = [];
|
|
286
|
+
for (const pending of carrying) {
|
|
287
|
+
if (pending.signal?.aborted) {
|
|
288
|
+
// Already rejected to its caller by the abort listener; do not
|
|
289
|
+
// resurrect it on the rebuilt agent.
|
|
290
|
+
this.clearPending(pending);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (isReplayableFrame(pending.lines)) {
|
|
294
|
+
if (pending.watchdog) {
|
|
295
|
+
clearTimeout(pending.watchdog);
|
|
296
|
+
pending.watchdog = null;
|
|
297
|
+
}
|
|
298
|
+
relayable.push(pending);
|
|
299
|
+
} else {
|
|
300
|
+
this.clearPending(pending);
|
|
301
|
+
pending.reject(new AgentUnavailableError(
|
|
302
|
+
`agent exited while request ${pending.id} was in flight; its state is unknown — inspect the distro before retrying`,
|
|
303
|
+
));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.start()
|
|
307
|
+
.then(() => {
|
|
308
|
+
for (const pending of relayable) {
|
|
309
|
+
const id = `r${this.nextRequestId++}`;
|
|
310
|
+
pending.id = id;
|
|
311
|
+
this.pending.set(id, pending);
|
|
312
|
+
this.writeLines(relabelFrame(pending.lines, id));
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
.catch((error) => {
|
|
316
|
+
for (const pending of relayable) {
|
|
317
|
+
this.clearPending(pending);
|
|
318
|
+
pending.reject(error);
|
|
319
|
+
}
|
|
320
|
+
this.state = "dead";
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.lastError = `agent exited again (code=${code} signal=${signalName ?? "none"}); falling back permanently`;
|
|
325
|
+
this.state = "dead";
|
|
326
|
+
for (const pending of this.pending.values()) {
|
|
327
|
+
this.clearPending(pending);
|
|
328
|
+
pending.reject(new AgentUnavailableError(this.lastError));
|
|
329
|
+
}
|
|
330
|
+
this.pending.clear();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Register one request's pending entry and write its frame: the shared
|
|
335
|
+
* request machinery (abort, submission, the ACK-armed watchdog). The
|
|
336
|
+
* watchdog is deliberately NOT armed here — it starts at the agent's
|
|
337
|
+
* dequeue `ACK`, so time spent queued behind an earlier request never
|
|
338
|
+
* spends this request's budget and never kills the shared transport for a
|
|
339
|
+
* stall that was not its own.
|
|
340
|
+
* @private
|
|
341
|
+
*/
|
|
342
|
+
track({ id, lines, resolve, reject, timeoutMs, signal }) {
|
|
343
|
+
const pending = {
|
|
344
|
+
id,
|
|
345
|
+
lines,
|
|
346
|
+
resolve,
|
|
347
|
+
reject,
|
|
348
|
+
watchdog: null,
|
|
349
|
+
signal: signal ?? null,
|
|
350
|
+
onAbort: null,
|
|
351
|
+
armWatchdog:
|
|
352
|
+
timeoutMs > 0
|
|
353
|
+
? () => {
|
|
354
|
+
if (pending.watchdog) return;
|
|
355
|
+
// The in-distro timeout does the killing; this only catches an
|
|
356
|
+
// agent that accepted the request and then stopped answering
|
|
357
|
+
// even to its own timer.
|
|
358
|
+
pending.watchdog = setTimeout(() => {
|
|
359
|
+
this.lastError = `agent did not answer request ${pending.id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms of its dispatch`;
|
|
360
|
+
this.process?.kill();
|
|
361
|
+
}, timeoutMs + WATCHDOG_GRACE_MS);
|
|
362
|
+
}
|
|
363
|
+
: null,
|
|
364
|
+
};
|
|
365
|
+
this.pending.set(id, pending);
|
|
366
|
+
if (signal) {
|
|
367
|
+
if (signal.aborted) {
|
|
368
|
+
this.pending.delete(id);
|
|
369
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
pending.onAbort = () => {
|
|
373
|
+
// Address by the entry's CURRENT id: a request relayed across a
|
|
374
|
+
// rebuild was re-registered under a fresh one, and a stale KILL (or a
|
|
375
|
+
// stale map delete) would strand the entry and leave the command
|
|
376
|
+
// running to its own timeout.
|
|
377
|
+
this.writeLines([encodeKill(pending.id)]);
|
|
378
|
+
this.clearPending(pending);
|
|
379
|
+
this.pending.delete(pending.id);
|
|
380
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
381
|
+
};
|
|
382
|
+
signal.addEventListener("abort", pending.onAbort, { once: true });
|
|
383
|
+
}
|
|
384
|
+
this.writeLines(lines);
|
|
385
|
+
this.rearmIdleTimer();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Run one command inside the distro through the agent.
|
|
390
|
+
* @param {object} request - the execution request.
|
|
391
|
+
* @param {string} request.cwd - Linux path to run in.
|
|
392
|
+
* @param {string[]} request.argv - the argv, already bwrap-wrapped when the
|
|
393
|
+
* caller confines.
|
|
394
|
+
* @param {number} [request.timeoutMs] - in-distro timeout, whole seconds
|
|
395
|
+
* upward, measured from the agent's dequeue ACK; `<= 0` or omitted means
|
|
396
|
+
* none. Queue time ahead of the dispatch is never counted.
|
|
397
|
+
* @param {Record<string, string>} [request.env] - extra environment for this
|
|
398
|
+
* request only; values lose trailing newlines (protocol limitation).
|
|
399
|
+
* @param {number} [request.maxOutputBytes] - per-stream capture ceiling the
|
|
400
|
+
* agent enforces (carried on the EXEC frame); the RES line reports which
|
|
401
|
+
* streams were cut. 0 (the default) keeps the unbounded capture.
|
|
402
|
+
* @param {AbortSignal} [request.signal] - cancels the request (KILL + reject).
|
|
403
|
+
* @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer, truncated: {stdout: boolean, stderr: boolean}}>}
|
|
404
|
+
* @throws {AgentUnavailableError} when the agent is out for this process.
|
|
405
|
+
*/
|
|
406
|
+
async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, signal }) {
|
|
407
|
+
if (this.state === "dead") {
|
|
408
|
+
throw new AgentUnavailableError(this.lastError);
|
|
409
|
+
}
|
|
410
|
+
await this.start();
|
|
411
|
+
const id = `r${this.nextRequestId++}`;
|
|
412
|
+
const lines = [
|
|
413
|
+
...(env ? Object.entries(env).map(([key, value]) => encodeSetEnv(key, value)) : []),
|
|
414
|
+
...encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }),
|
|
415
|
+
];
|
|
416
|
+
return new Promise((resolve, reject) => {
|
|
417
|
+
this.track({ id, lines, resolve, reject, timeoutMs, signal });
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Run one filesystem-substrate op inside the distro through the agent.
|
|
423
|
+
* Responses reuse the EXEC `ACK`/`RES` lines, so cancellation and the
|
|
424
|
+
* watchdog are exactly the command path's. The rebuild relay differs by op:
|
|
425
|
+
* a read-only op rides a rebuild, a `write` in flight fails with an
|
|
426
|
+
* unknown-state error instead of being replayed.
|
|
427
|
+
* @param {object} request - the filesystem request.
|
|
428
|
+
* @param {string} request.op - op name (`stat`, `lstat`, `list`, `realpath`,
|
|
429
|
+
* `read`, `write`), dispatched by `agent/wsl-agent.sh`.
|
|
430
|
+
* @param {string[]} request.args - op arguments; each rides one base64 line.
|
|
431
|
+
* @param {number} [request.timeoutMs] - in-distro timeout, whole seconds
|
|
432
|
+
* upward, measured from the agent's dequeue ACK; `<= 0` or omitted means
|
|
433
|
+
* none.
|
|
434
|
+
* @param {AbortSignal} [request.signal] - cancels the request (KILL + reject).
|
|
435
|
+
* @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer}>}
|
|
436
|
+
* @throws {AgentUnavailableError} when the agent is out for this process.
|
|
437
|
+
*/
|
|
438
|
+
async fs({ op, args, timeoutMs = 0, signal }) {
|
|
439
|
+
if (this.state === "dead") {
|
|
440
|
+
throw new AgentUnavailableError(this.lastError);
|
|
441
|
+
}
|
|
442
|
+
await this.start();
|
|
443
|
+
const id = `r${this.nextRequestId++}`;
|
|
444
|
+
const lines = encodeFsFrame({ id, op, args, timeoutMs });
|
|
445
|
+
return new Promise((resolve, reject) => {
|
|
446
|
+
this.track({ id, lines, resolve, reject, timeoutMs, signal });
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Liveness probe, also used to warm the agent up.
|
|
452
|
+
* @param {AbortSignal} [signal] - cancels the wait.
|
|
453
|
+
* @returns {Promise<void>} resolved on PONG.
|
|
454
|
+
*/
|
|
455
|
+
async ping(signal) {
|
|
456
|
+
if (this.state === "dead") throw new AgentUnavailableError(this.lastError);
|
|
457
|
+
await this.start();
|
|
458
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
const id = `r${this.nextRequestId++}`;
|
|
461
|
+
const lines = encodeExecFrame({ id, cwd: "/", argv: ["true"], timeoutMs: 0 });
|
|
462
|
+
this.track({ id, lines, resolve: () => resolve(), reject, timeoutMs: HANDSHAKE_TIMEOUT_MS, signal });
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Shut the agent down: SHUTDOWN first, then kill after a short grace. The
|
|
468
|
+
* agent becomes reusable — a later `exec` starts a fresh process.
|
|
469
|
+
* @returns {Promise<void>}
|
|
470
|
+
*/
|
|
471
|
+
async close() {
|
|
472
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
473
|
+
this.idleTimer = null;
|
|
474
|
+
const child = this.process;
|
|
475
|
+
if (!child) return;
|
|
476
|
+
this.state = "idle";
|
|
477
|
+
this.process = null;
|
|
478
|
+
this.writeLinesSafe(child, ["SHUTDOWN"]);
|
|
479
|
+
await new Promise((resolve) => {
|
|
480
|
+
const timer = setTimeout(() => {
|
|
481
|
+
child.kill("SIGKILL");
|
|
482
|
+
resolve();
|
|
483
|
+
}, 1000);
|
|
484
|
+
child.once("exit", () => {
|
|
485
|
+
clearTimeout(timer);
|
|
486
|
+
resolve();
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** @private */
|
|
492
|
+
writeLines(lines) {
|
|
493
|
+
const child = this.process;
|
|
494
|
+
if (!child || !child.stdin.writable) return;
|
|
495
|
+
child.stdin.write(`${lines.join("\n")}\n`);
|
|
496
|
+
this.rearmIdleTimer();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** @private */
|
|
500
|
+
writeLinesSafe(child, lines) {
|
|
501
|
+
try {
|
|
502
|
+
child.stdin?.write(`${lines.join("\n")}\n`);
|
|
503
|
+
} catch {
|
|
504
|
+
// A dead pipe is what close() is cleaning up after.
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** @private */
|
|
509
|
+
rearmIdleTimer() {
|
|
510
|
+
if (!this.idleMs) return;
|
|
511
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
512
|
+
this.idleTimer = setTimeout(() => {
|
|
513
|
+
this.idleTimer = null;
|
|
514
|
+
if (this.pending.size === 0) void this.close();
|
|
515
|
+
}, this.idleMs);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** @private */
|
|
519
|
+
clearPending(pending) {
|
|
520
|
+
if (pending.watchdog) clearTimeout(pending.watchdog);
|
|
521
|
+
if (pending.signal && pending.onAbort) pending.signal.removeEventListener("abort", pending.onAbort);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The read-only FS ops: replaying them across a rebuild changes nothing. */
|
|
526
|
+
const REPLAYABLE_FS_OPS = new Set(["stat", "lstat", "list", "realpath", "read"]);
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Whether a stored request frame's first execution provably had no side
|
|
530
|
+
* effects, so the frame may ride the one permitted rebuild. An EXEC never
|
|
531
|
+
* qualifies — a command's effects are unknowable after the agent died — and
|
|
532
|
+
* neither does an FS `write`. (A SETENV prefix line never decides: the frame
|
|
533
|
+
* header sits at `lines[0]` only for FS frames, which carry no SETENV.)
|
|
534
|
+
* @param {string[]} lines - the stored frame.
|
|
535
|
+
* @returns {boolean}
|
|
536
|
+
*/
|
|
537
|
+
function isReplayableFrame(lines) {
|
|
538
|
+
const header = lines[0] ?? "";
|
|
539
|
+
if (!header.startsWith("FS|")) return false;
|
|
540
|
+
return REPLAYABLE_FS_OPS.has(header.split("|")[2]);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Rewrite the request id inside a stored frame so a resent request does not
|
|
545
|
+
* collide with ids the new process has already seen. SETENV lines pass
|
|
546
|
+
* through unchanged (they are id-less and idempotent).
|
|
547
|
+
* @param {string[]} lines - the stored frame.
|
|
548
|
+
* @param {string} newId - the new request id.
|
|
549
|
+
* @returns {string[]} the relabelled frame.
|
|
550
|
+
*/
|
|
551
|
+
export function relabelFrame(lines, newId) {
|
|
552
|
+
return lines.map((line) => {
|
|
553
|
+
const separator = line.indexOf("|");
|
|
554
|
+
if (separator === -1) return line;
|
|
555
|
+
const kind = line.slice(0, separator);
|
|
556
|
+
if (kind !== "EXEC" && kind !== "FS" && kind !== "KILL") return line;
|
|
557
|
+
const rest = line.slice(separator + 1);
|
|
558
|
+
const pipe = rest.indexOf("|");
|
|
559
|
+
const tail = pipe === -1 ? "" : rest.slice(pipe);
|
|
560
|
+
return `${kind}|${newId}${tail}`;
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export default WslAgent;
|
package/lib/bwrap.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The distro-side `bwrap` command line, shared by every confinement site.
|
|
3
|
+
*
|
|
4
|
+
* Two callers compose a confined command and they must agree on it exactly:
|
|
5
|
+
* `lib/sandbox.js` wraps a *single* command (`confine()`, and the usability
|
|
6
|
+
* probe), and `lib/agent-confined.js` wraps a *resident* whose whole lifetime
|
|
7
|
+
* runs inside the profile — it hands the prefix to the transport, which inserts
|
|
8
|
+
* it between `wsl.exe --exec` and the `sh <script>` pair.
|
|
9
|
+
*
|
|
10
|
+
* That split is why this module exists. The two sites used to assemble the argv
|
|
11
|
+
* themselves, and only one of them prepended the program: the confined resident's
|
|
12
|
+
* prefix began at `--ro-bind`, so `wsl.exe --exec` tried to run a `--ro-bind`
|
|
13
|
+
* executable, the agent exited 1 during its handshake, and every confined
|
|
14
|
+
* mutation failed with `FS_IO_ERROR: … agent exited during handshake`. The
|
|
15
|
+
* verified probe could not see it because it inlined its own copy of the argv.
|
|
16
|
+
*
|
|
17
|
+
* This module imports nothing, so a bare checkout can assert the argv both sites
|
|
18
|
+
* actually produce.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-plugin-wsl/bwrap
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the bwrap profile arguments for one file-effect policy, exactly as
|
|
25
|
+
* upstream's `bwrapProfileArgs` does.
|
|
26
|
+
*
|
|
27
|
+
* `--ro-bind / /` makes the whole distro read-only, `--dev /dev` supplies the
|
|
28
|
+
* `/dev/null` sink a shell needs, `--unshare-pid` keeps the sandbox's process
|
|
29
|
+
* view its own, and `--proc /proc` gives it a matching `/proc`. Under
|
|
30
|
+
* `workspace-write` the workspace is bound read-write and `/tmp` becomes an
|
|
31
|
+
* ephemeral tmpfs — the temp area the mode promises, without exposing the
|
|
32
|
+
* distro's real `/tmp`.
|
|
33
|
+
*
|
|
34
|
+
* `options.maskWindowsDrive` mounts an empty tmpfs over `/mnt` AFTER the
|
|
35
|
+
* read-only root, shadowing the Windows drive: the drive's files disappear
|
|
36
|
+
* from the command's view, which severs interop as a side effect — a Windows
|
|
37
|
+
* executable on the drive can no longer be reached to launch, and the drive's
|
|
38
|
+
* data cannot be read or exfiltrated. It is a narrowing, not a closure: a
|
|
39
|
+
* command may still WRITE an executable into the workspace and run it
|
|
40
|
+
* (binfmt interop dispatches on file content, not location), so `enforcement`
|
|
41
|
+
* stays `partial` — see `lib/sandbox.js`.
|
|
42
|
+
*
|
|
43
|
+
* @param policy - the policy to express as bwrap mounts.
|
|
44
|
+
* @param options - profile options.
|
|
45
|
+
* @param options.maskWindowsDrive - shadow `/mnt` with an empty tmpfs.
|
|
46
|
+
* @returns profile arguments, before the `--` separator and the command argv.
|
|
47
|
+
*/
|
|
48
|
+
export function bwrapProfileArgs(policy, options = {}) {
|
|
49
|
+
const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
|
|
50
|
+
if (policy.mode === "workspace-write") {
|
|
51
|
+
args.push("--tmpfs", "/tmp");
|
|
52
|
+
args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
|
|
53
|
+
}
|
|
54
|
+
if (options.maskWindowsDrive) {
|
|
55
|
+
args.push("--tmpfs", "/mnt");
|
|
56
|
+
}
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The argv prefix that runs one command (or one resident) inside the profile.
|
|
62
|
+
*
|
|
63
|
+
* The prefix is a complete command from the distro's point of view: it names the
|
|
64
|
+
* program (`bwrap`), the profile, and the `--` separator that ends bwrap's own
|
|
65
|
+
* options. `workspaceRoot` must already be spelled as a Linux path inside the
|
|
66
|
+
* distro; the callers own that translation.
|
|
67
|
+
*
|
|
68
|
+
* @param policy - the resolved policy: `mode`, plus the Linux `workspaceRoot`.
|
|
69
|
+
* @param options - profile options, passed through to {@link bwrapProfileArgs}.
|
|
70
|
+
* @returns argv to insert before the command, as the transport receives it.
|
|
71
|
+
*/
|
|
72
|
+
export function bwrapArgvPrefix(policy, options = {}) {
|
|
73
|
+
return ["bwrap", ...bwrapProfileArgs(policy, options), "--"];
|
|
74
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-distro publication step for `WslFileSystem.writeText`/`editText`.
|
|
3
|
+
*
|
|
4
|
+
* Today's publication is two round trips: `copyModeInDistro` (one `wsl.exe`
|
|
5
|
+
* chmod) plus the staged file's rename over the 9p share. Under the resident
|
|
6
|
+
* agent it collapses into ONE `exec`: a small POSIX shell step that copies the
|
|
7
|
+
* replaced file's mode onto the staged temp (`chmod --reference`, skipped for
|
|
8
|
+
* a creation, where there is nothing to copy from) and renames it into place
|
|
9
|
+
* atomically — the rename preserving the inode's mode bits, exactly as the
|
|
10
|
+
* old path arranged.
|
|
11
|
+
*
|
|
12
|
+
* Pure by construction: this module only builds the argv; execution and
|
|
13
|
+
* fallback belong to the caller.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-wsl/fs-publish
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The publication script. `$1` is the target's Linux path (may not exist —
|
|
20
|
+
* a creation), `$2` the staged temp's Linux path (always exists).
|
|
21
|
+
* @returns {string} the `sh -c` body.
|
|
22
|
+
*/
|
|
23
|
+
export function publicationScript() {
|
|
24
|
+
return [
|
|
25
|
+
// Creation: there is no source mode; the staged file keeps its umask mode,
|
|
26
|
+
// which is exactly what the old two-step path produced for new files.
|
|
27
|
+
'if [ -e "$1" ]; then',
|
|
28
|
+
' chmod --reference="$1" "$2" || exit 3',
|
|
29
|
+
'fi',
|
|
30
|
+
'mv -f "$2" "$1" || exit 4',
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The argv to execute inside the distro for one publication.
|
|
36
|
+
* @param {string} targetLinuxPath - the file being written.
|
|
37
|
+
* @param {string} stagedLinuxPath - the staged temp holding the new content.
|
|
38
|
+
* @returns {string[]} the argv, ready for the agent (or a one-shot `wsl.exe`).
|
|
39
|
+
*/
|
|
40
|
+
export function publicationArgv(targetLinuxPath, stagedLinuxPath) {
|
|
41
|
+
return ["sh", "-c", publicationScript(), "publish", targetLinuxPath, stagedLinuxPath];
|
|
42
|
+
}
|