dsh-plugin-wsl-env 0.2.0 → 0.4.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/lib/agent.js CHANGED
@@ -5,16 +5,25 @@
5
5
  * distro. The lifetime rules, in order of importance:
6
6
  *
7
7
  * 1. **Correctness before speed.** Any agent failure is answered by at most
8
- * one rebuild; the requests that were in flight ride the rebuild. A second
9
- * failure marks the agent permanently dead and every future call rejects
10
- * with {@link AgentUnavailableError} — the executor's cue to fall back to
11
- * the one-shot `wsl.exe` path it shipped with, so the worst case of the
12
- * agent is "today's behaviour, plus one failed round trip".
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".
13
18
  * 2. **The in-distro timeout is the real one.** The EXEC frame carries the
14
19
  * timeout in whole seconds and the agent SIGTERMs the child itself, so a
15
20
  * timed-out command dies where it lives instead of leaving an orphan
16
- * behind a dead `wsl.exe`. A host-side watchdog only guards an agent that
17
- * stopped answering at all.
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.
18
27
  * 3. **Idle agents go away.** `idleMs` after the last exchange the process is
19
28
  * killed; the next call starts a fresh one. WSL VMs are shared, but every
20
29
  * process is a promise someone has to keep.
@@ -26,9 +35,8 @@
26
35
  */
27
36
 
28
37
  import { spawn } from "node:child_process";
29
- import { createInterface } from "node:readline";
30
38
  import { AgentUnavailableError, CwdError } from "./agent-errors.js";
31
- import { AGENT_NAME, PROTOCOL_VERSION, encodeExecFrame, encodeKill, encodeSetEnv, parseAgentLine } from "./agent-protocol.js";
39
+ import { AGENT_NAME, MAX_FRAME_BYTES, PROTOCOL_VERSION, encodeExecFrame, encodeFsFrame, encodeKill, encodeSetEnv, parseAgentLine } from "./agent-protocol.js";
32
40
 
33
41
  /** How long a rebuilt agent has to re-answer the handshake before giving up. */
34
42
  const HANDSHAKE_TIMEOUT_MS = 15000;
@@ -36,6 +44,50 @@ const HANDSHAKE_TIMEOUT_MS = 15000;
36
44
  /** Extra time the host watchdog waits past the request's own in-distro timeout. */
37
45
  const WATCHDOG_GRACE_MS = 13000;
38
46
 
47
+ /** The Windows essentials `wsl.exe` itself needs to start and report cleanly. */
48
+ const WINDOWS_ESSENTIALS = /^(systemroot|systemdrive|windir|path|pathext|comspec|os|computername)$/i;
49
+
50
+ /** The managed facts prefix whose names (and only theirs) may be forwarded. */
51
+ const MANAGED_ENV_PREFIX = "DSH_";
52
+
53
+ /**
54
+ * The Windows environment the transport starts from, pinned.
55
+ *
56
+ * Spawned bare, `wsl.exe` would inherit this process's whole environment — and
57
+ * the `WSLENV` variable riding along decides which of those names are
58
+ * FORWARDED into the distro, where every model command inherits them. Dev
59
+ * machines routinely route secrets that way (`WSLENV=GITHUB_TOKEN/u` is the
60
+ * documented example), so the resident's env is built from scratch instead:
61
+ * the Windows essentials `wsl.exe` itself needs, the managed `DSH_*` facts,
62
+ * and a `WSLENV` that names only those managed entries — preserving their
63
+ * original forwarding flags. Nothing else on the Windows side can reach a
64
+ * model command.
65
+ *
66
+ * Deliberately NOT here: the executor's `forwardEnv` names. They ride the
67
+ * one-shot path's WSLENV (`spawnSpec` builds it per call); the resident path
68
+ * has never forwarded them, and this pin keeps that posture instead of
69
+ * widening it silently.
70
+ *
71
+ * @param {Record<string, string|undefined>} parent - this process's environment.
72
+ * @returns {Record<string, string>} the pinned environment for `wsl.exe`.
73
+ */
74
+ export function pinnedWindowsEnv(parent) {
75
+ const env = {};
76
+ for (const [name, value] of Object.entries(parent)) {
77
+ if (value === undefined) continue;
78
+ if (WINDOWS_ESSENTIALS.test(name) || name.startsWith(MANAGED_ENV_PREFIX)) env[name] = value;
79
+ }
80
+ env.WSLENV = String(parent.WSLENV ?? "")
81
+ .split(":")
82
+ .filter((entry) => entry.startsWith(MANAGED_ENV_PREFIX))
83
+ .join(":");
84
+ // wsl.exe writes its own diagnostics as UTF-16LE without this, and a UTF-16
85
+ // message read as UTF-8 is mojibake — including the `WSL_E_*` codes. Same
86
+ // pin the one-shot path applies to every spawn.
87
+ env.WSL_UTF8 = "1";
88
+ return env;
89
+ }
90
+
39
91
  /**
40
92
  * @param {object} options - the spawn options.
41
93
  * @param {string} options.wslPath - the `wsl.exe` path.
@@ -45,8 +97,9 @@ const WATCHDOG_GRACE_MS = 13000;
45
97
  * `agent/wsl-agent.sh`).
46
98
  * @returns {import("node:child_process").ChildProcess} the spawned process.
47
99
  */
48
- function spawnDefault({ wslPath, distro, scriptPath }) {
49
- return spawn(wslPath, ["-d", distro, "--exec", "sh", scriptPath], {
100
+ function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [] }) {
101
+ return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
102
+ env: pinnedWindowsEnv(process.env),
50
103
  stdio: ["pipe", "pipe", "pipe"],
51
104
  windowsHide: true,
52
105
  });
@@ -63,14 +116,22 @@ export class WslAgent {
63
116
  * inside the distro (see {@link spawnDefault}).
64
117
  * @param {string} [config.wslPath] - the `wsl.exe` path.
65
118
  * @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
66
- * @param {(options: {wslPath: string, distro: string, scriptPath: string}) => import("node:child_process").ChildProcess} [config.spawnTransport]
119
+ * @param {string[]} [config.argvPrefix] - arguments inserted between
120
+ * `wsl.exe --exec` and the `sh script` pair — the confinement wrapper
121
+ * (`bwrap … --`) a confined agent runs its whole lifetime under, so every
122
+ * filesystem op it performs is kernel-enforced. Empty for the plain agent.
123
+ * @param {(options: {wslPath: string, distro: string, scriptPath: string, argvPrefix: string[]}) => import("node:child_process").ChildProcess} [config.spawnTransport]
67
124
  * injectable process factory for tests.
68
125
  */
69
126
  constructor(config) {
70
127
  this.distro = config.distro;
71
128
  this.scriptPath = config.scriptPath;
72
129
  this.wslPath = config.wslPath ?? "wsl.exe";
130
+ this.argvPrefix = config.argvPrefix ?? [];
73
131
  this.idleMs = config.idleMs ?? 600000;
132
+ /** Hard ceiling on one stdout protocol line; a longer line is a broken or
133
+ * hostile agent, not data to hold — readline used to accumulate it all. */
134
+ this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
74
135
  this.configuredSpawn = config.spawnTransport;
75
136
  /** @type {import("node:child_process").ChildProcess|null} */
76
137
  this.process = null;
@@ -117,6 +178,7 @@ export class WslAgent {
117
178
  wslPath: this.wslPath,
118
179
  distro: this.distro,
119
180
  scriptPath: this.scriptPath,
181
+ argvPrefix: this.argvPrefix,
120
182
  }));
121
183
  const handshakeTimer = setTimeout(() => {
122
184
  this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
@@ -145,8 +207,34 @@ export class WslAgent {
145
207
  }
146
208
  this.handleProcessExit(code, signalName);
147
209
  });
148
- const out = createInterface({ input: child.stdout });
149
- out.on("line", (line) => this.handleLine(line, settleStart, handshakeTimer));
210
+ // A bounded line splitter instead of readline: readline accumulates an
211
+ // over-long line whole before anyone can object, which is exactly the
212
+ // unbounded-capture hole. Protocol lines are ASCII by construction
213
+ // (ids, numbers, base64), so chunk-boundary decoding is not a concern.
214
+ let buffered = "";
215
+ child.stdout.on("data", (chunk) => {
216
+ buffered += chunk.toString("utf8");
217
+ for (;;) {
218
+ const newline = buffered.indexOf("\n");
219
+ if (newline === -1) {
220
+ if (buffered.length > this.frameCapBytes) {
221
+ this.lastError = `agent frame of ${buffered.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
222
+ buffered = "";
223
+ this.process?.kill();
224
+ }
225
+ return;
226
+ }
227
+ const line = buffered.slice(0, newline);
228
+ buffered = buffered.slice(newline + 1);
229
+ if (line.length > this.frameCapBytes) {
230
+ this.lastError = `agent frame of ${line.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
231
+ buffered = "";
232
+ this.process?.kill();
233
+ return;
234
+ }
235
+ this.handleLine(line, settleStart, handshakeTimer);
236
+ }
237
+ });
150
238
  child.stderr?.on("data", (chunk) => {
151
239
  // Diagnostics only; `wsl.exe` and the shell chatter here on failure.
152
240
  this.lastError = String(chunk).trim().slice(0, 2000) || this.lastError;
@@ -173,13 +261,22 @@ export class WslAgent {
173
261
  settleStart();
174
262
  break;
175
263
  }
264
+ case "ack": {
265
+ // Dequeue signal: start this request's own budget clock NOW. Armed
266
+ // here instead of at submission, a queued request can no longer time
267
+ // out on a queue it never chose to join — and its watchdog can never
268
+ // kill the shared transport for a stall that was never its own.
269
+ const pending = this.pending.get(message.id);
270
+ pending?.armWatchdog?.();
271
+ break;
272
+ }
176
273
  case "result": {
177
274
  const pending = this.pending.get(message.id);
178
275
  if (!pending) break; // answered after its caller gave up — noise
179
276
  this.clearPending(pending);
180
277
  this.pending.delete(message.id);
181
278
  this.rearmIdleTimer();
182
- pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr });
279
+ pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr, truncated: message.truncated });
183
280
  break;
184
281
  }
185
282
  case "agentError": {
@@ -223,16 +320,51 @@ export class WslAgent {
223
320
  this.startPromise = null;
224
321
  const carrying = [...this.pending.values()];
225
322
  this.pending.clear();
323
+ // Only provably read-only FS requests ride the rebuild: whatever their
324
+ // first execution did, it changed nothing. A command or a write that was
325
+ // in flight has UNKNOWN state — replaying it would run non-idempotent
326
+ // work a second time, or report a guarded create that actually
327
+ // succeeded as a refusal — so it fails with that fact instead. A stale
328
+ // watchdog must not survive either: its closure kills `this.process`,
329
+ // which after the rebuild is the NEW transport.
330
+ const relayable = [];
331
+ for (const pending of carrying) {
332
+ if (pending.signal?.aborted) {
333
+ // Already rejected to its caller by the abort listener; do not
334
+ // resurrect it on the rebuilt agent.
335
+ this.clearPending(pending);
336
+ continue;
337
+ }
338
+ if (isReplayableFrame(pending.lines)) {
339
+ if (pending.watchdog) {
340
+ clearTimeout(pending.watchdog);
341
+ pending.watchdog = null;
342
+ }
343
+ relayable.push(pending);
344
+ } else {
345
+ this.clearPending(pending);
346
+ pending.reject(new AgentUnavailableError(
347
+ `agent exited while request ${pending.id} was in flight; its state is unknown — inspect the distro before retrying`,
348
+ ));
349
+ }
350
+ }
226
351
  this.start()
227
352
  .then(() => {
228
- for (const pending of carrying) {
353
+ for (const pending of relayable) {
229
354
  const id = `r${this.nextRequestId++}`;
355
+ pending.id = id;
230
356
  this.pending.set(id, pending);
231
- this.writeLines(relabelFrame(pending.lines, id));
357
+ // The rebuilt transport can itself be gone by now; a frame that
358
+ // cannot be written must fail its caller, not strand the entry.
359
+ if (!this.writeLines(relabelFrame(pending.lines, id))) {
360
+ this.clearPending(pending);
361
+ this.pending.delete(id);
362
+ pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
363
+ }
232
364
  }
233
365
  })
234
366
  .catch((error) => {
235
- for (const pending of carrying) {
367
+ for (const pending of relayable) {
236
368
  this.clearPending(pending);
237
369
  pending.reject(error);
238
370
  }
@@ -249,6 +381,70 @@ export class WslAgent {
249
381
  this.pending.clear();
250
382
  }
251
383
 
384
+ /**
385
+ * Register one request's pending entry and write its frame: the shared
386
+ * request machinery (abort, submission, the ACK-armed watchdog). The
387
+ * watchdog is deliberately NOT armed here — it starts at the agent's
388
+ * dequeue `ACK`, so time spent queued behind an earlier request never
389
+ * spends this request's budget and never kills the shared transport for a
390
+ * stall that was not its own.
391
+ * @private
392
+ */
393
+ track({ id, lines, resolve, reject, timeoutMs, signal }) {
394
+ const pending = {
395
+ id,
396
+ lines,
397
+ resolve,
398
+ reject,
399
+ watchdog: null,
400
+ signal: signal ?? null,
401
+ onAbort: null,
402
+ armWatchdog:
403
+ timeoutMs > 0
404
+ ? () => {
405
+ if (pending.watchdog) return;
406
+ // The in-distro timeout does the killing; this only catches an
407
+ // agent that accepted the request and then stopped answering
408
+ // even to its own timer.
409
+ pending.watchdog = setTimeout(() => {
410
+ this.lastError = `agent did not answer request ${pending.id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms of its dispatch`;
411
+ this.process?.kill();
412
+ }, timeoutMs + WATCHDOG_GRACE_MS);
413
+ }
414
+ : null,
415
+ };
416
+ this.pending.set(id, pending);
417
+ if (signal) {
418
+ if (signal.aborted) {
419
+ this.pending.delete(id);
420
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
421
+ return;
422
+ }
423
+ pending.onAbort = () => {
424
+ // Address by the entry's CURRENT id: a request relayed across a
425
+ // rebuild was re-registered under a fresh one, and a stale KILL (or a
426
+ // stale map delete) would strand the entry and leave the command
427
+ // running to its own timeout.
428
+ this.writeLines([encodeKill(pending.id)]);
429
+ this.clearPending(pending);
430
+ this.pending.delete(pending.id);
431
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
432
+ };
433
+ signal.addEventListener("abort", pending.onAbort, { once: true });
434
+ }
435
+ // A frame that cannot be WRITTEN must fail its caller now. Silently
436
+ // dropping it used to strand the request: nothing would ever answer, the
437
+ // watchdog (armed on an ACK that will not come) had no process to kill,
438
+ // and with no timeout the caller waited forever.
439
+ if (!this.writeLines(lines)) {
440
+ this.clearPending(pending);
441
+ this.pending.delete(pending.id);
442
+ reject(new AgentUnavailableError("the agent exited before the request could be sent; it never ran"));
443
+ return;
444
+ }
445
+ this.rearmIdleTimer();
446
+ }
447
+
252
448
  /**
253
449
  * Run one command inside the distro through the agent.
254
450
  * @param {object} request - the execution request.
@@ -256,14 +452,18 @@ export class WslAgent {
256
452
  * @param {string[]} request.argv - the argv, already bwrap-wrapped when the
257
453
  * caller confines.
258
454
  * @param {number} [request.timeoutMs] - in-distro timeout, whole seconds
259
- * upward; `<= 0` or omitted means none.
455
+ * upward, measured from the agent's dequeue ACK; `<= 0` or omitted means
456
+ * none. Queue time ahead of the dispatch is never counted.
260
457
  * @param {Record<string, string>} [request.env] - extra environment for this
261
458
  * request only; values lose trailing newlines (protocol limitation).
459
+ * @param {number} [request.maxOutputBytes] - per-stream capture ceiling the
460
+ * agent enforces (carried on the EXEC frame); the RES line reports which
461
+ * streams were cut. 0 (the default) keeps the unbounded capture.
262
462
  * @param {AbortSignal} [request.signal] - cancels the request (KILL + reject).
263
- * @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer}>}
463
+ * @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer, truncated: {stdout: boolean, stderr: boolean}}>}
264
464
  * @throws {AgentUnavailableError} when the agent is out for this process.
265
465
  */
266
- async exec({ cwd, argv, timeoutMs = 0, env, signal }) {
466
+ async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, signal }) {
267
467
  if (this.state === "dead") {
268
468
  throw new AgentUnavailableError(this.lastError);
269
469
  }
@@ -271,35 +471,39 @@ export class WslAgent {
271
471
  const id = `r${this.nextRequestId++}`;
272
472
  const lines = [
273
473
  ...(env ? Object.entries(env).map(([key, value]) => encodeSetEnv(key, value)) : []),
274
- ...encodeExecFrame({ id, cwd, argv, timeoutMs }),
474
+ ...encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }),
275
475
  ];
276
476
  return new Promise((resolve, reject) => {
277
- const pending = { lines, resolve, reject, watchdog: null, signal, onAbort: null };
278
- this.pending.set(id, pending);
279
- if (signal) {
280
- if (signal.aborted) {
281
- this.pending.delete(id);
282
- reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
283
- return;
284
- }
285
- pending.onAbort = () => {
286
- this.writeLines([encodeKill(id)]);
287
- this.clearPending(pending);
288
- this.pending.delete(id);
289
- reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
290
- };
291
- signal.addEventListener("abort", pending.onAbort, { once: true });
292
- }
293
- if (timeoutMs > 0) {
294
- // The in-distro timeout does the killing; this only catches an agent
295
- // that stopped answering even to its own timer.
296
- pending.watchdog = setTimeout(() => {
297
- this.lastError = `agent did not answer request ${id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms`;
298
- this.process?.kill();
299
- }, timeoutMs + WATCHDOG_GRACE_MS);
300
- }
301
- this.writeLines(lines);
302
- this.rearmIdleTimer();
477
+ this.track({ id, lines, resolve, reject, timeoutMs, signal });
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Run one filesystem-substrate op inside the distro through the agent.
483
+ * Responses reuse the EXEC `ACK`/`RES` lines, so cancellation and the
484
+ * watchdog are exactly the command path's. The rebuild relay differs by op:
485
+ * a read-only op rides a rebuild, a `write` in flight fails with an
486
+ * unknown-state error instead of being replayed.
487
+ * @param {object} request - the filesystem request.
488
+ * @param {string} request.op - op name (`stat`, `lstat`, `list`, `realpath`,
489
+ * `read`, `write`), dispatched by `agent/wsl-agent.sh`.
490
+ * @param {string[]} request.args - op arguments; each rides one base64 line.
491
+ * @param {number} [request.timeoutMs] - in-distro timeout, whole seconds
492
+ * upward, measured from the agent's dequeue ACK; `<= 0` or omitted means
493
+ * none.
494
+ * @param {AbortSignal} [request.signal] - cancels the request (KILL + reject).
495
+ * @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer}>}
496
+ * @throws {AgentUnavailableError} when the agent is out for this process.
497
+ */
498
+ async fs({ op, args, timeoutMs = 0, signal }) {
499
+ if (this.state === "dead") {
500
+ throw new AgentUnavailableError(this.lastError);
501
+ }
502
+ await this.start();
503
+ const id = `r${this.nextRequestId++}`;
504
+ const lines = encodeFsFrame({ id, op, args, timeoutMs });
505
+ return new Promise((resolve, reject) => {
506
+ this.track({ id, lines, resolve, reject, timeoutMs, signal });
303
507
  });
304
508
  }
305
509
 
@@ -315,19 +519,7 @@ export class WslAgent {
315
519
  return new Promise((resolve, reject) => {
316
520
  const id = `r${this.nextRequestId++}`;
317
521
  const lines = encodeExecFrame({ id, cwd: "/", argv: ["true"], timeoutMs: 0 });
318
- const pending = {
319
- lines,
320
- resolve: () => resolve(),
321
- reject,
322
- watchdog: setTimeout(() => {
323
- this.lastError = "agent did not answer ping";
324
- this.process?.kill();
325
- }, HANDSHAKE_TIMEOUT_MS),
326
- signal: null,
327
- onAbort: null,
328
- };
329
- this.pending.set(id, pending);
330
- this.writeLines(lines);
522
+ this.track({ id, lines, resolve: () => resolve(), reject, timeoutMs: HANDSHAKE_TIMEOUT_MS, signal });
331
523
  });
332
524
  }
333
525
 
@@ -359,15 +551,20 @@ export class WslAgent {
359
551
  /** @private */
360
552
  writeLines(lines) {
361
553
  const child = this.process;
362
- if (!child || !child.stdin.writable) return;
554
+ if (!child || !child.stdin.writable) return false;
363
555
  child.stdin.write(`${lines.join("\n")}\n`);
364
556
  this.rearmIdleTimer();
557
+ return true;
365
558
  }
366
559
 
367
560
  /** @private */
368
561
  writeLinesSafe(child, lines) {
562
+ // The writable check matters beyond politeness: a write to an ENDED stdin
563
+ // does not throw here — it emits an async 'error' event the caller never
564
+ // listens for, which crashes the process during its own shutdown.
565
+ if (!child.stdin || !child.stdin.writable) return;
369
566
  try {
370
- child.stdin?.write(`${lines.join("\n")}\n`);
567
+ child.stdin.write(`${lines.join("\n")}\n`);
371
568
  } catch {
372
569
  // A dead pipe is what close() is cleaning up after.
373
570
  }
@@ -390,6 +587,24 @@ export class WslAgent {
390
587
  }
391
588
  }
392
589
 
590
+ /** The read-only FS ops: replaying them across a rebuild changes nothing. */
591
+ const REPLAYABLE_FS_OPS = new Set(["stat", "lstat", "list", "realpath", "read"]);
592
+
593
+ /**
594
+ * Whether a stored request frame's first execution provably had no side
595
+ * effects, so the frame may ride the one permitted rebuild. An EXEC never
596
+ * qualifies — a command's effects are unknowable after the agent died — and
597
+ * neither does an FS `write`. (A SETENV prefix line never decides: the frame
598
+ * header sits at `lines[0]` only for FS frames, which carry no SETENV.)
599
+ * @param {string[]} lines - the stored frame.
600
+ * @returns {boolean}
601
+ */
602
+ function isReplayableFrame(lines) {
603
+ const header = lines[0] ?? "";
604
+ if (!header.startsWith("FS|")) return false;
605
+ return REPLAYABLE_FS_OPS.has(header.split("|")[2]);
606
+ }
607
+
393
608
  /**
394
609
  * Rewrite the request id inside a stored frame so a resent request does not
395
610
  * collide with ids the new process has already seen. SETENV lines pass
@@ -403,7 +618,7 @@ export function relabelFrame(lines, newId) {
403
618
  const separator = line.indexOf("|");
404
619
  if (separator === -1) return line;
405
620
  const kind = line.slice(0, separator);
406
- if (kind !== "EXEC" && kind !== "KILL") return line;
621
+ if (kind !== "EXEC" && kind !== "FS" && kind !== "KILL") return line;
407
622
  const rest = line.slice(separator + 1);
408
623
  const pipe = rest.indexOf("|");
409
624
  const tail = pipe === -1 ? "" : rest.slice(pipe);
package/lib/bwrap.js ADDED
@@ -0,0 +1,91 @@
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
+ * `options.realTmp` changes what workspace-write does at `/tmp`. The default
44
+ * (`--tmpfs /tmp`) gives the sandbox an ephemeral, empty temp area — the right
45
+ * shape for a COMMAND: fresh per run, cannot touch the distro's real `/tmp`,
46
+ * cannot be poisoned by a previous run. `realTmp: true` binds the REAL `/tmp`
47
+ * read-write instead — the right shape for the FILE-WRITES resident: the
48
+ * fence grants `/tmp` as a writable root, so a write the fence approved must
49
+ * land where every reader (the plain agent, the user's own shell) reads — not
50
+ * inside a private tmpfs that reads cannot see and that dies with the
51
+ * resident. Read-only mode mounts no `/tmp` at all, so the option is inert
52
+ * there.
53
+ *
54
+ * @param policy - the policy to express as bwrap mounts.
55
+ * @param options - profile options.
56
+ * @param options.maskWindowsDrive - shadow `/mnt` with an empty tmpfs.
57
+ * @param options.realTmp - workspace-write binds the real `/tmp` read-write
58
+ * instead of mounting an ephemeral tmpfs over it.
59
+ * @returns profile arguments, before the `--` separator and the command argv.
60
+ */
61
+ export function bwrapProfileArgs(policy, options = {}) {
62
+ const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
63
+ if (policy.mode === "workspace-write") {
64
+ if (options.realTmp) {
65
+ args.push("--bind", "/tmp", "/tmp");
66
+ } else {
67
+ args.push("--tmpfs", "/tmp");
68
+ }
69
+ args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
70
+ }
71
+ if (options.maskWindowsDrive) {
72
+ args.push("--tmpfs", "/mnt");
73
+ }
74
+ return args;
75
+ }
76
+
77
+ /**
78
+ * The argv prefix that runs one command (or one resident) inside the profile.
79
+ *
80
+ * The prefix is a complete command from the distro's point of view: it names the
81
+ * program (`bwrap`), the profile, and the `--` separator that ends bwrap's own
82
+ * options. `workspaceRoot` must already be spelled as a Linux path inside the
83
+ * distro; the callers own that translation.
84
+ *
85
+ * @param policy - the resolved policy: `mode`, plus the Linux `workspaceRoot`.
86
+ * @param options - profile options, passed through to {@link bwrapProfileArgs}.
87
+ * @returns argv to insert before the command, as the transport receives it.
88
+ */
89
+ export function bwrapArgvPrefix(policy, options = {}) {
90
+ return ["bwrap", ...bwrapProfileArgs(policy, options), "--"];
91
+ }