dsh-plugin-wsl-env 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +488 -12
  2. package/CONTRIBUTING.md +125 -62
  3. package/README.md +99 -68
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +26 -10
  6. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  7. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  8. package/agent/wsl-agent.sh +83 -16
  9. package/cordis.patch.yml +50 -35
  10. package/docs/ARCHITECTURE.md +203 -55
  11. package/docs/CONFIGURATION.md +185 -18
  12. package/docs/LIMITATIONS.md +125 -45
  13. package/docs/PARITY.md +68 -38
  14. package/docs/PEER-PARITY.md +103 -33
  15. package/docs/README.zh.md +105 -64
  16. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  17. package/docs/UPSTREAM-TERMINAL-TITLE.md +56 -14
  18. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  19. package/lib/agent-protocol.d.ts +10 -4
  20. package/lib/agent-protocol.js +10 -3
  21. package/lib/agent.d.ts +84 -0
  22. package/lib/agent.js +167 -15
  23. package/lib/bwrap.d.ts +8 -0
  24. package/lib/bwrap.js +4 -1
  25. package/lib/fs-routing.d.ts +40 -4
  26. package/lib/fs-routing.js +80 -12
  27. package/lib/fsio-agent.js +1 -1
  28. package/lib/index.d.ts +134 -0
  29. package/lib/index.js +202 -9
  30. package/lib/native-agent.d.ts +124 -0
  31. package/lib/native-agent.js +198 -0
  32. package/lib/ports.d.ts +13 -2
  33. package/lib/ports.js +18 -5
  34. package/lib/preflight.d.ts +91 -0
  35. package/lib/preflight.js +117 -0
  36. package/lib/probe-cache.d.ts +12 -1
  37. package/lib/probe-cache.js +11 -3
  38. package/lib/sandbox-core.d.ts +70 -0
  39. package/lib/sandbox-core.js +187 -18
  40. package/lib/session-runtime.d.ts +161 -0
  41. package/lib/session-runtime.js +220 -0
  42. package/lib/session-scope.d.ts +124 -0
  43. package/lib/session-scope.js +191 -0
  44. package/lib/shell-env.js +10 -3
  45. package/lib/watcher.d.ts +25 -12
  46. package/lib/watcher.js +120 -32
  47. package/lib/workspace-files-route.d.ts +8 -4
  48. package/lib/workspace-files-route.js +42 -14
  49. package/lib/workspace-files-wsl.js +25 -8
  50. package/lib/wsl.js +10 -4
  51. package/locale/preflight/en.json +6 -0
  52. package/locale/preflight/zh.json +6 -0
  53. package/package.json +44 -27
  54. package/scripts/diagnose.sh +92 -0
package/lib/index.js CHANGED
@@ -47,6 +47,7 @@ import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSign
47
47
  import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, posixToUnc, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
48
48
  import { isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, shareSubstrateRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
49
49
  import { WslSandbox } from "./sandbox.js";
50
+ import { bwrapFailure, missingBwrapRemedy, withBwrapHint } from "./sandbox-core.js";
50
51
  import { armDistroWatcher } from "./watcher.js";
51
52
  import { AgentUnavailableError } from "./agent-errors.js";
52
53
  import { agentExecutionHandle } from "./agent-exec.js";
@@ -54,9 +55,11 @@ import { sharedAgent } from "./agent-shared.js";
54
55
  import { AgentSubstrate } from "./fs-substrate.js";
55
56
  import { FsCodedError } from "./fsio-text.js";
56
57
  import { confinedAgent } from "./agent-confined.js";
58
+ import { SessionRuntimes, probeSessionRuntime, sessionRuntimes, setSessionNativeAgent } from "./session-runtime.js";
59
+ import { configureAgentBackend, ensureNativeSessionAgent } from "./native-agent.js";
57
60
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
58
61
  // can use them without pulling this file's executor and filesystem peers in.
59
- import { defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
62
+ import { defaultDistro, defaultShell, linuxHomePath, runCapture, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
60
63
  /**
61
64
  * Every model shell call gets a rebuilt managed `DSH_*` namespace (see
62
65
  * `dsh-shell-env` / `ctx.shellEnv`). Those names exist only in the Windows-side
@@ -81,6 +84,14 @@ const SAFE_FORWARD = ["NO_COLOR", "TERM", "PAGER", "GIT_PAGER", "LANG", "LC_ALL"
81
84
  * @param config - a provider's config.
82
85
  * @returns the configured directory, trimmed, or `""`.
83
86
  */
87
+ /**
88
+ * Opt into the native agent for one distro when `DSH_WSL_AGENT_NATIVE` names a
89
+ * host binary. The copy/verify is cached in `native-agent.ts`; a failure (or
90
+ * an unset variable) leaves the shell agent in place.
91
+ *
92
+ * @param wslPath - the `wsl.exe` path.
93
+ * @param distro - the resolved distro.
94
+ */
84
95
  function configuredCwd(config) {
85
96
  return String(config.cwd?.get?.() ?? "").trim();
86
97
  }
@@ -212,6 +223,17 @@ export class WslShellExecutor extends LocalBashExecutor {
212
223
  * decoration are identical on both paths.
213
224
  */
214
225
  agent: z.boolean().default(true),
226
+ agentBackend: z.union(["auto", "native", "shell"]).default("auto"),
227
+ /**
228
+ * Run this session's commands in one persistent systemd user scope (ADR
229
+ * 0001): background processes survive the command that started them, `/tmp`
230
+ * is the distro's real `/tmp` under workspace-write, and `ps` sees the
231
+ * session. The executor holds the runtime until the session's scope
232
+ * disposes, then stops it. Requires systemd, a reachable user manager and
233
+ * `Linger`; the preflight refuses with the remedy when one is missing, and
234
+ * only a success is cached. Off by default; requires `agent: true`.
235
+ */
236
+ sessionSandbox: z.boolean().default(false),
215
237
  });
216
238
  /**
217
239
  * @param ctx - the composition context.
@@ -219,12 +241,20 @@ export class WslShellExecutor extends LocalBashExecutor {
219
241
  */
220
242
  constructor(ctx, config) {
221
243
  super(ctx, config);
244
+ configureAgentBackend(config.agentBackend ?? "auto");
222
245
  this.resolvedDistro = config.distro;
223
246
  this.resolvedShell = config.shell;
224
247
  this.resolvedHome = undefined;
225
248
  this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
226
249
  this.sandbox = config.sandbox ? new WslSandbox({ wslPath: config.wslPath }) : undefined;
227
250
  this.processFacts = new Map();
251
+ this.sessionRuntimes = sessionRuntimes;
252
+ this.sessionRuntimeEntries = new Map();
253
+ this.sessionScopeProbe = (argv) => runCapture(argv);
254
+ this.sessionRuntimePreflight = "unknown";
255
+ // The preset realm disposes when the session ends; the scope must go with
256
+ // it, or a dev server outlives the session that started it.
257
+ ctx.effect(() => () => this.disposeSessionRuntimes().catch(() => { }), "wsl session runtimes");
228
258
  }
229
259
  /**
230
260
  * The mode this executor confines with, or `undefined` when it does not
@@ -301,6 +331,71 @@ export class WslShellExecutor extends LocalBashExecutor {
301
331
  executionAgent(distro) {
302
332
  return sharedAgent(distro);
303
333
  }
334
+ /**
335
+ * The persistent session runtime for one policy, when `sessionSandbox` is
336
+ * on. The executor holds one reference per `(distro, workspace, policy)` and
337
+ * releases them when the session's scope disposes.
338
+ *
339
+ * @param policy - the resolved file-effect policy, when the executor has one.
340
+ * @param resolved - the resolved spec, for the fallback workspace.
341
+ * @param distro - the resolved distro name.
342
+ * @returns the runtime entry, or undefined when the mode is off.
343
+ */
344
+ sessionRuntimeFor(policy, resolved, distro) {
345
+ const config = this.config;
346
+ if (!config.sessionSandbox)
347
+ return undefined;
348
+ const mode = policy?.mode ?? "danger-full-access";
349
+ const workspaceRoot = policy?.workspaceRoot ?? (configuredCwd(this.config) || resolved.workdir);
350
+ const key = SessionRuntimes.key({ distro, workspaceRoot, mode, maskWindowsDrive: config.maskWindowsDrive });
351
+ const held = this.sessionRuntimeEntries.get(key);
352
+ if (held !== undefined)
353
+ return held;
354
+ const entry = this.sessionRuntimes.acquire({
355
+ distro,
356
+ wslPath: config.wslPath,
357
+ workspaceRoot,
358
+ mode,
359
+ maskWindowsDrive: config.maskWindowsDrive,
360
+ });
361
+ this.sessionRuntimeEntries.set(key, entry);
362
+ return entry;
363
+ }
364
+ /**
365
+ * Run the session runtime's preflight once per executor. A failure is a
366
+ * configuration verdict, not an agent outage: it is raised as an Error with
367
+ * the remedy, so the command fails closed instead of falling back to the
368
+ * per-command sandbox. Only success is cached, so a user who follows the
369
+ * remedy (`loginctl enable-linger`, `[boot] systemd=true`) does not need to
370
+ * restart the app.
371
+ *
372
+ * @param distro - the resolved distro name.
373
+ * @returns resolved once the platform can host a session runtime.
374
+ * @throws {Error} with the remedy when it cannot.
375
+ */
376
+ async ensureSessionRuntimeUsable(distro) {
377
+ if (this.sessionRuntimePreflight === "ready")
378
+ return;
379
+ const config = this.config;
380
+ const problem = await probeSessionRuntime({ wslPath: config.wslPath, distro, run: this.sessionScopeProbe });
381
+ if (problem !== null)
382
+ throw new Error(problem);
383
+ await ensureNativeSessionAgent({ wslPath: config.wslPath, distro, setNative: setSessionNativeAgent });
384
+ this.sessionRuntimePreflight = "ready";
385
+ }
386
+ /**
387
+ * Release every runtime this executor holds, stopping each scope. Called
388
+ * from the session's dispose hook; public so tests and shutdown paths can
389
+ * await it.
390
+ *
391
+ * @returns resolved once every runtime is torn down.
392
+ */
393
+ async disposeSessionRuntimes() {
394
+ const entries = [...this.sessionRuntimeEntries.values()];
395
+ this.sessionRuntimeEntries.clear();
396
+ for (const entry of entries)
397
+ await this.sessionRuntimes.release(entry.key);
398
+ }
304
399
  /**
305
400
  * The argv that runs INSIDE the distro for one spec: the resolved shell, its
306
401
  * login/command flags, and the command string. This is the argv a
@@ -603,14 +698,25 @@ export class WslShellExecutor extends LocalBashExecutor {
603
698
  ? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
604
699
  : undefined;
605
700
  const mode = policy?.mode;
701
+ if (config.sessionSandbox)
702
+ await this.ensureSessionRuntimeUsable(distro);
703
+ const runtime = this.sessionRuntimeFor(policy, resolved, distro);
704
+ const agent = runtime !== undefined ? runtime.agent : await this.executionAgent(distro);
705
+ // A session runtime's own bwrap profile applies the mounts, so the
706
+ // command must NOT be wrapped a second time: the per-command probe still
707
+ // runs (it is the bwrap availability check and the denial facts), but its
708
+ // argv is discarded.
709
+ const argv = runtime !== undefined
710
+ ? this.distroArgv(resolved, shell)
711
+ : confined !== undefined ? confined.argv : this.distroArgv(resolved, shell);
606
712
  const execution = agentExecutionHandle({
607
- agent: await this.executionAgent(distro),
713
+ agent,
608
714
  // The one-shot path converts the spec's workdir into the distro's
609
715
  // coordinate system inside argv(); the agent carries the cwd as a frame
610
716
  // field, so the SAME conversion happens here — a raw UNC must never
611
717
  // reach `chdir` (that is the bug this line fixes).
612
718
  cwd: toLinuxPath(resolved.workdir, { distro }),
613
- argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
719
+ argv,
614
720
  timeoutMs: resolved.timeoutMs,
615
721
  // The agent enforces the collect budget distro-side (the EXEC frame
616
722
  // carries it); `maxSpillBytes` stays one-shot-only — the agent path has
@@ -649,7 +755,10 @@ export class WslShellExecutor extends LocalBashExecutor {
649
755
  const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
650
756
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
651
757
  if (runnerFailure !== undefined) {
652
- throw new SandboxUnavailableError(mode, runnerFailure.detail);
758
+ // This path computes `mode` as `policy?.mode`, which the seam spells as
759
+ // the wider union: a runner failure is only ever thrown under a
760
+ // confinement, and there the mode is one of the two confined ones.
761
+ throw new SandboxUnavailableError(mode, withBwrapHint(runnerFailure.detail, distro));
653
762
  }
654
763
  return ({
655
764
  ...result,
@@ -732,7 +841,7 @@ export class WslShellExecutor extends LocalBashExecutor {
732
841
  // Runner failure outranks denial because the command did not run at all.
733
842
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
734
843
  if (runnerFailure !== undefined)
735
- throw new SandboxUnavailableError(mode, runnerFailure.detail);
844
+ throw new SandboxUnavailableError(mode, withBwrapHint(runnerFailure.detail, distro));
736
845
  return ({
737
846
  ...result,
738
847
  sandbox: {
@@ -747,7 +856,7 @@ export class WslShellExecutor extends LocalBashExecutor {
747
856
  if (resolved.signal?.aborted === true)
748
857
  resolved.signal.throwIfAborted();
749
858
  if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], resolved.workdir)) {
750
- throw new SandboxUnavailableError(mode, String(error));
859
+ throw new SandboxUnavailableError(mode, withBwrapHint(String(error), distro));
751
860
  }
752
861
  throw error;
753
862
  });
@@ -776,6 +885,17 @@ export class WslFileSystem extends LocalFileSystem {
776
885
  * operations are unconfined.
777
886
  */
778
887
  sandbox: z.boolean().default(true),
888
+ /**
889
+ * Run confined mutations in the session's systemd user scope — the same
890
+ * runtime the shell executor acquires for the same (distro, workspace,
891
+ * policy) — instead of a per-policy confined resident. The kernel
892
+ * enforcement is the runtime's mount profile, so commands and writes see
893
+ * the same `/tmp` and workspace; the scope stops when the session's
894
+ * providers release it. Requires systemd + `Linger`, like `wsl-shell`'s
895
+ * `sessionSandbox`; the preflight refuses with the remedy. Off for direct
896
+ * constructions; on in the shipped preset.
897
+ */
898
+ sessionSandbox: z.boolean().default(false),
779
899
  /**
780
900
  * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
781
901
  * Windows drive from confined commands — same meaning and same honest
@@ -835,6 +955,13 @@ export class WslFileSystem extends LocalFileSystem {
835
955
  this.agentSubstrateInstance = undefined;
836
956
  this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
837
957
  this.agentInstance = undefined;
958
+ this.sessionRuntimes = sessionRuntimes;
959
+ this.sessionRuntimeEntries = new Map();
960
+ this.sessionScopeProbe = (argv) => runCapture(argv);
961
+ this.sessionRuntimePreflight = "unknown";
962
+ // The preset's disposer releases the runtime; the shell executor's effect
963
+ // releases its own reference, so the scope stops after the last one.
964
+ ctx.effect(() => () => this.disposeSessionRuntimes().catch(() => { }), "wsl fs session runtimes");
838
965
  }
839
966
  /**
840
967
  * The mode this backend fences mutations with, or `undefined` when the
@@ -879,6 +1006,65 @@ export class WslFileSystem extends LocalFileSystem {
879
1006
  }
880
1007
  return this.agentSubstrateInstance;
881
1008
  }
1009
+ /**
1010
+ * The persistent session runtime for one confined mutation, when
1011
+ * `sessionSandbox` is on. The key is the shell executor's key for the same
1012
+ * session/policy, so the write and the commands share one agent, one mount
1013
+ * view and one scope; the provider holds a reference until the session ends.
1014
+ *
1015
+ * @param policy - the resolved confined policy.
1016
+ * @param distro - the resolved distro name.
1017
+ * @returns the runtime entry, or undefined when the mode is off.
1018
+ */
1019
+ sessionRuntimeFor(policy, distro) {
1020
+ const config = this.config;
1021
+ if (!config.sessionSandbox)
1022
+ return undefined;
1023
+ const workspaceRoot = policy.workspaceRoot || config.cwd;
1024
+ const key = SessionRuntimes.key({ distro, workspaceRoot, mode: policy.mode, maskWindowsDrive: config.maskWindowsDrive });
1025
+ const held = this.sessionRuntimeEntries.get(key);
1026
+ if (held !== undefined)
1027
+ return held;
1028
+ const entry = this.sessionRuntimes.acquire({
1029
+ distro,
1030
+ wslPath: config.wslPath,
1031
+ workspaceRoot,
1032
+ mode: policy.mode,
1033
+ maskWindowsDrive: config.maskWindowsDrive,
1034
+ });
1035
+ this.sessionRuntimeEntries.set(key, entry);
1036
+ return entry;
1037
+ }
1038
+ /**
1039
+ * Run the session runtime's preflight once per provider. Only success is
1040
+ * cached, so following the remedy does not need an app restart.
1041
+ *
1042
+ * @param distro - the resolved distro name.
1043
+ * @returns resolved once the platform can host the runtime.
1044
+ * @throws {Error} with the remedy when it cannot.
1045
+ */
1046
+ async ensureSessionRuntimeUsable(distro) {
1047
+ if (this.sessionRuntimePreflight === "ready")
1048
+ return;
1049
+ const config = this.config;
1050
+ const problem = await probeSessionRuntime({ wslPath: config.wslPath, distro, run: this.sessionScopeProbe });
1051
+ if (problem !== null)
1052
+ throw new Error(problem);
1053
+ await ensureNativeSessionAgent({ wslPath: config.wslPath, distro, setNative: setSessionNativeAgent });
1054
+ this.sessionRuntimePreflight = "ready";
1055
+ }
1056
+ /**
1057
+ * Release every runtime this provider holds. Called from the preset's
1058
+ * dispose effect; public so tests and shutdown paths can await it.
1059
+ *
1060
+ * @returns resolved once every reference is released.
1061
+ */
1062
+ async disposeSessionRuntimes() {
1063
+ const entries = [...this.sessionRuntimeEntries.values()];
1064
+ this.sessionRuntimeEntries.clear();
1065
+ for (const entry of entries)
1066
+ await this.sessionRuntimes.release(entry.key);
1067
+ }
882
1068
  /**
883
1069
  * The agent a MUTATION runs on under this resolved policy — stage-two
884
1070
  * routing. A confined policy addresses the confined resident whose bwrap
@@ -903,9 +1089,16 @@ export class WslFileSystem extends LocalFileSystem {
903
1089
  const confinedPolicy = policy;
904
1090
  const distro = await this.distro();
905
1091
  if (!(await this.sandbox.usable(distro))) {
906
- throw new SandboxUnavailableError(confinedPolicy.mode, `bwrap is not usable inside distro "${distro}". Install it with ` +
907
- `"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
908
- `wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`);
1092
+ // The probe's classified remedy — missing vs present-but-broken, with the
1093
+ // distro's own package family — is what every other refusal surfaces;
1094
+ // this path has the same failure, so it carries the same text.
1095
+ throw new SandboxUnavailableError(confinedPolicy.mode, bwrapFailure(config.wslPath, distro) ?? missingBwrapRemedy(distro));
1096
+ }
1097
+ if (config.sessionSandbox) {
1098
+ await this.ensureSessionRuntimeUsable(distro);
1099
+ const runtime = this.sessionRuntimeFor(confinedPolicy, distro);
1100
+ if (runtime !== undefined)
1101
+ return runtime.agent;
909
1102
  }
910
1103
  return confinedAgent({ distro, wslPath: config.wslPath, policy: confinedPolicy, maskWindowsDrive: config.maskWindowsDrive });
911
1104
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Host-side plumbing for the optional native in-distro agent (ADR 0002).
3
+ *
4
+ * The shell agent is the shipped peer. This module is the opt-in path that
5
+ * lets the host run the spike/native binary instead, for the session runtime
6
+ * only:
7
+ *
8
+ * 1. read the binary from a host path (`DSH_WSL_AGENT_NATIVE`);
9
+ * 2. sha256 it and refuse an architecture the binary was not built for;
10
+ * 3. stream it into `~/.cache/dsh-wsl/agent/<sha>/dsh-wsl-agent` inside the
11
+ * distro, chmod 0755, and verify the distro-side digest;
12
+ * 4. hand the resulting Linux path to the session-runtime factory.
13
+ *
14
+ * The binary is never executed from `/mnt/c` (DrvFs is often `noexec`), and a
15
+ * failed copy falls back to the shell agent — this module never throws to the
16
+ * command path.
17
+ *
18
+ * This is a TypeScript source built to `lib/native-agent.js`; edit THIS file
19
+ * and run `pnpm run build` — the artifact under `lib/` is generated, and
20
+ * `pnpm test` fails when it drifts.
21
+ *
22
+ * @module dsh-plugin-wsl/native-agent
23
+ */
24
+ /** The host environment variable naming the native binary to opt in. */
25
+ export declare const NATIVE_AGENT_ENV = "DSH_WSL_AGENT_NATIVE";
26
+ /**
27
+ * The in-distro copy/verify script. The digest is embedded so `sh` needs no
28
+ * second argument and the cache path is derived from the content; stdout is
29
+ * the final Linux path the session runtime should execute.
30
+ *
31
+ * @param digest - lowercase hex sha256 of the binary.
32
+ * @returns the `sh -c` body (binary on stdin).
33
+ */
34
+ export declare function nativeCacheScript(digest: string): string;
35
+ /** The injectable edges. */
36
+ export interface NativeBackendOptions {
37
+ /** The `wsl.exe` path. */
38
+ wslPath: string;
39
+ /** The distro. */
40
+ distro: string;
41
+ /**
42
+ * The host path override (the development variable); empty resolves the
43
+ * packaged binary for the distro's architecture.
44
+ */
45
+ source: string;
46
+ /** The plugin package root; tests and non-standard layouts substitute it. */
47
+ packageRoot?: string;
48
+ /** Read the binary (tests substitute). */
49
+ readSource?: (path: string) => Promise<Buffer>;
50
+ /** Run a host command and return stdout (tests substitute). */
51
+ run?: (argv: string[]) => Promise<string>;
52
+ /** Copy the binary and return the Linux path (tests substitute). */
53
+ copy?: (script: string, binary: Buffer) => Promise<string>;
54
+ }
55
+ /** A prepared native binary: its in-distro path and the sha256 of its bytes. */
56
+ export interface NativeBackendResult {
57
+ /** The Linux path the session runtime spawns. */
58
+ path: string;
59
+ /** Lowercase hex sha256; the agent's HELLO digest must match it. */
60
+ digest: string;
61
+ }
62
+ /**
63
+ * Copy the binary into the distro and verify it. The result is cached per
64
+ * `(wslPath, distro, source)`; a failure is cached as `undefined` for this
65
+ * process (the caller falls back to the shell agent).
66
+ *
67
+ * @param options - the transport, distro, source and injectables.
68
+ * @returns the Linux binary path and digest, or undefined when unusable.
69
+ */
70
+ export declare function ensureNativeBackend({ wslPath, distro, source, packageRoot, readSource, run, copy, }: NativeBackendOptions): Promise<NativeBackendResult | undefined>;
71
+ /**
72
+ * Forget the cache; tests only.
73
+ */
74
+ export declare function clearNativeBackendCache(): void;
75
+ /**
76
+ * Stream the binary to the distro on stdin, run the copy/verify script and
77
+ * return its stdout (the cache path).
78
+ *
79
+ * Exported for its regression check; the copy path is injectable in
80
+ * {@link ensureNativeBackend}.
81
+ *
82
+ * @param wslPath - the `wsl.exe` path.
83
+ * @param distro - the distro.
84
+ * @param script - the copy/verify script.
85
+ * @param binary - the binary bytes.
86
+ * @returns the Linux path printed by the script.
87
+ */
88
+ export declare function copyBinary(wslPath: string, distro: string, script: string, binary: Buffer): Promise<string>;
89
+ /** The session runtime's agent backend, from `wsl-shell.agentBackend`. */
90
+ export type AgentBackend = "auto" | "native" | "shell";
91
+ /**
92
+ * Record the selected backend. The session runtime is shared by the shell and
93
+ * filesystem providers, so this one process-wide value is what both read when
94
+ * they prepare their runtime; it is set from the `wsl-shell` config.
95
+ *
96
+ * @param mode - the selected backend.
97
+ */
98
+ export declare function configureAgentBackend(mode: AgentBackend): void;
99
+ /** @returns the configured backend (for diagnostics and tests). */
100
+ export declare function configuredAgentBackend(): AgentBackend;
101
+ /**
102
+ * Select the native binary for a distro when the backend allows it.
103
+ *
104
+ * - `shell`: clear any native entry and use the shipped shell agent.
105
+ * - `auto`: use the native binary when a source exists and copy/verify
106
+ * succeeds; otherwise stay on the shell agent.
107
+ * - `native`: same preparation, but a missing source or a failed preparation
108
+ * throws a remedy instead of silently falling back.
109
+ *
110
+ * The source is the development override `DSH_WSL_AGENT_NATIVE`; the packaged
111
+ * per-arch binary will be added by the packaging task and preferred over it.
112
+ *
113
+ * @param options - the transport, distro, setter and injectable prepare step.
114
+ */
115
+ export declare function ensureNativeSessionAgent({ wslPath, distro, setNative, ensure, }: {
116
+ /** The `wsl.exe` path. */
117
+ wslPath: string;
118
+ /** The resolved distro. */
119
+ distro: string;
120
+ /** Store (or clear, with an empty path) the distro's native entrypoint. */
121
+ setNative: (distro: string, path: string, digest: string) => void;
122
+ /** Prepare the binary; tests substitute. */
123
+ ensure?: (options: NativeBackendOptions) => Promise<NativeBackendResult | undefined>;
124
+ }): Promise<void>;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Host-side plumbing for the optional native in-distro agent (ADR 0002).
3
+ *
4
+ * The shell agent is the shipped peer. This module is the opt-in path that
5
+ * lets the host run the spike/native binary instead, for the session runtime
6
+ * only:
7
+ *
8
+ * 1. read the binary from a host path (`DSH_WSL_AGENT_NATIVE`);
9
+ * 2. sha256 it and refuse an architecture the binary was not built for;
10
+ * 3. stream it into `~/.cache/dsh-wsl/agent/<sha>/dsh-wsl-agent` inside the
11
+ * distro, chmod 0755, and verify the distro-side digest;
12
+ * 4. hand the resulting Linux path to the session-runtime factory.
13
+ *
14
+ * The binary is never executed from `/mnt/c` (DrvFs is often `noexec`), and a
15
+ * failed copy falls back to the shell agent — this module never throws to the
16
+ * command path.
17
+ *
18
+ * This is a TypeScript source built to `lib/native-agent.js`; edit THIS file
19
+ * and run `pnpm run build` — the artifact under `lib/` is generated, and
20
+ * `pnpm test` fails when it drifts.
21
+ *
22
+ * @module dsh-plugin-wsl/native-agent
23
+ */
24
+ import { spawn } from "node:child_process";
25
+ import { createHash } from "node:crypto";
26
+ import { readFile } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { pinnedWindowsEnv } from "./agent.js";
30
+ import { runCapture } from "./wsl.js";
31
+ /** The host environment variable naming the native binary to opt in. */
32
+ export const NATIVE_AGENT_ENV = "DSH_WSL_AGENT_NATIVE";
33
+ /** Distro `uname -m` values mapped to the packaged directory names. */
34
+ const ARCHS = new Map([
35
+ ["x86_64", "x86_64"],
36
+ ["amd64", "x86_64"],
37
+ ["aarch64", "aarch64"],
38
+ ["arm64", "aarch64"],
39
+ ]);
40
+ /** The plugin package root: `lib/native-agent.js` -> `<root>`. */
41
+ const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
42
+ /**
43
+ * The in-distro copy/verify script. The digest is embedded so `sh` needs no
44
+ * second argument and the cache path is derived from the content; stdout is
45
+ * the final Linux path the session runtime should execute.
46
+ *
47
+ * @param digest - lowercase hex sha256 of the binary.
48
+ * @returns the `sh -c` body (binary on stdin).
49
+ */
50
+ export function nativeCacheScript(digest) {
51
+ return [
52
+ "set -e",
53
+ "umask 077",
54
+ `dir="$HOME/.cache/dsh-wsl/agent/${digest}"`,
55
+ 'target="$dir/dsh-wsl-agent"',
56
+ // A live session already runs this exact binary; rewriting it would fail
57
+ // with ETXTBSY. A valid cache is the same verified artifact, so reuse it.
58
+ `if [ -x "$target" ] && [ "$(sha256sum "$target" | cut -d' ' -f1)" = "${digest}" ]; then printf '%s' "$target"; exit 0; fi`,
59
+ 'mkdir -p "$dir"',
60
+ 'cat > "$target"',
61
+ 'chmod 0755 "$target"',
62
+ "got=$(sha256sum \"$target\" | cut -d' ' -f1)",
63
+ `[ "$got" = "${digest}" ] || { echo digest-mismatch >&2; exit 1; }`,
64
+ '"$target" </dev/null >/dev/null 2>&1 || { echo cannot-execute >&2; exit 1; }',
65
+ 'printf "%s" "$target"',
66
+ ].join("\n");
67
+ }
68
+ /**
69
+ * Copy the binary into the distro and verify it. The result is cached per
70
+ * `(wslPath, distro, source)`; a failure is cached as `undefined` for this
71
+ * process (the caller falls back to the shell agent).
72
+ *
73
+ * @param options - the transport, distro, source and injectables.
74
+ * @returns the Linux binary path and digest, or undefined when unusable.
75
+ */
76
+ export async function ensureNativeBackend({ wslPath, distro, source, packageRoot = PACKAGE_ROOT, readSource = readFile, run = (argv) => runCapture(argv), copy, }) {
77
+ const key = `${wslPath}\u0000${distro}\u0000${source}\u0000${packageRoot}`;
78
+ const cached = NATIVE_CACHE.get(key);
79
+ if (cached !== undefined)
80
+ return cached;
81
+ const pending = (async () => {
82
+ const arch = (await run([wslPath, "-d", distro, "--exec", "uname", "-m"])).trim();
83
+ const normalized = ARCHS.get(arch);
84
+ if (normalized === undefined)
85
+ return undefined;
86
+ const hostPath = source.length > 0 ? source : join(packageRoot, "agent", "native", `linux-${normalized}`, "dsh-wsl-agent");
87
+ const binary = await readSource(hostPath);
88
+ const digest = createHash("sha256").update(binary).digest("hex");
89
+ const install = copy ?? ((script, bytes) => copyBinary(wslPath, distro, script, bytes));
90
+ const path = await install(nativeCacheScript(digest), binary);
91
+ return path.length > 0 ? { path, digest } : undefined;
92
+ })().catch(() => undefined);
93
+ NATIVE_CACHE.set(key, pending);
94
+ return pending;
95
+ }
96
+ /** The per-process cache; a failure is remembered as undefined. */
97
+ const NATIVE_CACHE = new Map();
98
+ /**
99
+ * Forget the cache; tests only.
100
+ */
101
+ export function clearNativeBackendCache() {
102
+ NATIVE_CACHE.clear();
103
+ }
104
+ /**
105
+ * Stream the binary to the distro on stdin, run the copy/verify script and
106
+ * return its stdout (the cache path).
107
+ *
108
+ * Exported for its regression check; the copy path is injectable in
109
+ * {@link ensureNativeBackend}.
110
+ *
111
+ * @param wslPath - the `wsl.exe` path.
112
+ * @param distro - the distro.
113
+ * @param script - the copy/verify script.
114
+ * @param binary - the binary bytes.
115
+ * @returns the Linux path printed by the script.
116
+ */
117
+ export function copyBinary(wslPath, distro, script, binary) {
118
+ return new Promise((resolve, reject) => {
119
+ const child = spawn(wslPath, ["-d", distro, "--exec", "sh", "-c", script, "sh"], {
120
+ stdio: ["pipe", "pipe", "pipe"],
121
+ windowsHide: true,
122
+ env: pinnedWindowsEnv(process.env),
123
+ });
124
+ let stdout = "";
125
+ let stderr = "";
126
+ child.stdout?.setEncoding("utf8");
127
+ child.stdout?.on("data", (chunk) => {
128
+ stdout += chunk;
129
+ });
130
+ child.stderr?.setEncoding("utf8");
131
+ child.stderr?.on("data", (chunk) => {
132
+ stderr += chunk;
133
+ });
134
+ child.on("error", reject);
135
+ child.on("exit", (code) => {
136
+ if (code === 0)
137
+ resolve(stdout.trim());
138
+ else
139
+ reject(new Error(stderr.trim() || `native agent copy exited with ${code}`));
140
+ });
141
+ // The script prints the cached path and exits WITHOUT reading stdin when
142
+ // the in-distro digest already matches. On Windows, finishing the binary
143
+ // write against that closed pipe surfaces as `write EOF` on stdin, and an
144
+ // unhandled stream error would take down the host process. The exit code
145
+ // and stdout above already decide the result, so absorb the write error.
146
+ child.stdin?.on("error", () => { });
147
+ try {
148
+ child.stdin?.end(binary);
149
+ }
150
+ catch {
151
+ // A pipe that is already gone is the same warm-cache case.
152
+ }
153
+ });
154
+ }
155
+ /** The process-wide mode; the shell executor's constructor owns it. */
156
+ let backend = "auto";
157
+ /**
158
+ * Record the selected backend. The session runtime is shared by the shell and
159
+ * filesystem providers, so this one process-wide value is what both read when
160
+ * they prepare their runtime; it is set from the `wsl-shell` config.
161
+ *
162
+ * @param mode - the selected backend.
163
+ */
164
+ export function configureAgentBackend(mode) {
165
+ backend = mode;
166
+ }
167
+ /** @returns the configured backend (for diagnostics and tests). */
168
+ export function configuredAgentBackend() {
169
+ return backend;
170
+ }
171
+ /**
172
+ * Select the native binary for a distro when the backend allows it.
173
+ *
174
+ * - `shell`: clear any native entry and use the shipped shell agent.
175
+ * - `auto`: use the native binary when a source exists and copy/verify
176
+ * succeeds; otherwise stay on the shell agent.
177
+ * - `native`: same preparation, but a missing source or a failed preparation
178
+ * throws a remedy instead of silently falling back.
179
+ *
180
+ * The source is the development override `DSH_WSL_AGENT_NATIVE`; the packaged
181
+ * per-arch binary will be added by the packaging task and preferred over it.
182
+ *
183
+ * @param options - the transport, distro, setter and injectable prepare step.
184
+ */
185
+ export async function ensureNativeSessionAgent({ wslPath, distro, setNative, ensure = ensureNativeBackend, }) {
186
+ if (backend === "shell") {
187
+ setNative(distro, "", "");
188
+ return;
189
+ }
190
+ const result = await ensure({ wslPath, distro, source: process.env[NATIVE_AGENT_ENV] ?? "" });
191
+ if (result === undefined) {
192
+ if (backend === "native") {
193
+ throw new Error(`agentBackend: "native" could not prepare a native binary: the packaged binary for this architecture is missing or failed the copy/digest/execute check, and ${NATIVE_AGENT_ENV} is unset or unusable; set a valid ${NATIVE_AGENT_ENV}, or use "auto"/"shell".`);
194
+ }
195
+ return;
196
+ }
197
+ setNative(distro, result.path, result.digest);
198
+ }
package/lib/ports.d.ts CHANGED
@@ -38,7 +38,18 @@ export declare function procNetScript(): string;
38
38
  *
39
39
  * @param runner - the {@link WslAgent}-like runner.
40
40
  * @param timeoutMs - the exec budget.
41
- * @returns listening ports, ascending; [] on any failure — a snapshot is a
42
- * convenience, never an error the session should see.
41
+ * @returns listening ports, ascending; undefined when the scan could not
42
+ * answer (agent out, non-zero exit), so a caller can keep the last snapshot.
43
+ */
44
+ export declare function scanListeningPorts(runner: AgentExecRunner, timeoutMs?: number): Promise<number[] | undefined>;
45
+ /**
46
+ * Snapshot the distro's listening ports, collapsing a failed scan to an empty
47
+ * list. New callers that must distinguish "none listening" from "the scan
48
+ * failed" should use {@linkcode scanListeningPorts}; this wrapper exists for
49
+ * the probe/test shape that only needs the list.
50
+ *
51
+ * @param runner - the agent-like runner.
52
+ * @param timeoutMs - the exec budget.
53
+ * @returns listening ports, ascending; [] on a failed scan.
43
54
  */
44
55
  export declare function listeningPorts(runner: AgentExecRunner, timeoutMs?: number): Promise<number[]>;