dsh-plugin-wsl-env 0.9.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 (44) hide show
  1. package/CHANGELOG.md +417 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +84 -68
  4. package/SUPPORT.md +3 -3
  5. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  6. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  7. package/agent/wsl-agent.sh +83 -16
  8. package/cordis.patch.yml +41 -35
  9. package/docs/ARCHITECTURE.md +9 -1
  10. package/docs/CONFIGURATION.md +7 -4
  11. package/docs/LIMITATIONS.md +53 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +97 -74
  15. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  16. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  17. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  18. package/lib/agent-protocol.d.ts +10 -4
  19. package/lib/agent-protocol.js +10 -3
  20. package/lib/agent.d.ts +84 -0
  21. package/lib/agent.js +167 -15
  22. package/lib/bwrap.d.ts +8 -0
  23. package/lib/bwrap.js +4 -1
  24. package/lib/fs-routing.d.ts +40 -4
  25. package/lib/fs-routing.js +80 -12
  26. package/lib/fsio-agent.js +1 -1
  27. package/lib/index.d.ts +134 -0
  28. package/lib/index.js +191 -3
  29. package/lib/native-agent.d.ts +124 -0
  30. package/lib/native-agent.js +198 -0
  31. package/lib/ports.d.ts +13 -2
  32. package/lib/ports.js +18 -5
  33. package/lib/preflight.js +24 -1
  34. package/lib/session-runtime.d.ts +161 -0
  35. package/lib/session-runtime.js +220 -0
  36. package/lib/session-scope.d.ts +124 -0
  37. package/lib/session-scope.js +191 -0
  38. package/lib/shell-env.js +10 -3
  39. package/lib/watcher.d.ts +25 -12
  40. package/lib/watcher.js +120 -32
  41. package/lib/workspace-files-route.d.ts +8 -4
  42. package/lib/workspace-files-route.js +42 -14
  43. package/lib/workspace-files-wsl.js +25 -8
  44. package/package.json +34 -25
package/lib/fs-routing.js CHANGED
@@ -39,6 +39,47 @@ import { WslFileSystem } from "./index.js";
39
39
  * @param targetKey - the target's identity (a UNC for distro paths).
40
40
  * @returns true when the target lives inside a WSL distro.
41
41
  */
42
+ /**
43
+ * Lexically normalize a Linux path (`/a/./b/../c` → `/a/c`). The resolver
44
+ * canonicalizes real paths, so this is a guard for target keys that did not
45
+ * come through it; it cannot follow symlinks, and does not pretend to.
46
+ *
47
+ * @param path - the Linux path.
48
+ * @returns the normalized absolute path.
49
+ */
50
+ function normalizeLinuxPath(path) {
51
+ const segments = [];
52
+ for (const segment of path.split("/")) {
53
+ if (segment === "" || segment === ".")
54
+ continue;
55
+ if (segment === "..")
56
+ segments.pop();
57
+ else
58
+ segments.push(segment);
59
+ }
60
+ return `/${segments.join("/")}`;
61
+ }
62
+ /**
63
+ * Whether a distro target contains another, comparing BOTH the distro
64
+ * coordinate and the normalized Linux path. A child in another distro's
65
+ * share is never contained, even when its Linux spelling is a prefix match —
66
+ * the textual check this replaces said yes there.
67
+ *
68
+ * @param parentKey - the parent's UNC identity.
69
+ * @param childKey - the child's UNC identity.
70
+ * @returns true when the child is the parent or lies under it in the same distro.
71
+ */
72
+ export function distroContains(parentKey, childKey) {
73
+ const parent = uncToPosix(parentKey);
74
+ const child = uncToPosix(childKey);
75
+ if (parent === undefined || child === undefined)
76
+ return false;
77
+ if (parent.distro.toLowerCase() !== child.distro.toLowerCase())
78
+ return false;
79
+ const base = normalizeLinuxPath(parent.linuxPath);
80
+ const full = normalizeLinuxPath(child.linuxPath);
81
+ return full === base || full.startsWith(base.endsWith("/") ? base : `${base}/`);
82
+ }
42
83
  export function targetIsDistro(targetKey) {
43
84
  return isWslUnc(targetKey);
44
85
  }
@@ -61,23 +102,46 @@ export function inputIsDistro(value) {
61
102
  */
62
103
  export class WslRoutingFileSystem extends LocalFileSystem {
63
104
  static Config = LocalFileSystem.Config;
105
+ /**
106
+ * The routed backend's constructor reads the deployment default mode from
107
+ * `ctx.sandboxPolicy`, exactly as the shipped `fs-sandbox` row this class
108
+ * replaces does. The service is provided by a sibling row, not an ancestor,
109
+ * so the fiber must declare the dependency; without it the lazy backend
110
+ * construction fails with "cannot get property sandboxPolicy without inject".
111
+ */
112
+ static inject = ["sandboxPolicy"];
64
113
  /**
65
114
  * The lazy distro backend, built on first routed use, typed as the concrete
66
115
  * class: these routing calls are that class's own surface, and the
67
116
  * `processPathFromHostPath` seam it once could not satisfy (async against the
68
117
  * peer interface's synchronous contract) is closed — the backend maps
69
118
  * synchronously now, from what it already knows.
119
+ *
120
+ * Plain fields on purpose: cordis invokes a service method with a shadow
121
+ * receiver (the traceable proxy `ReflectService` builds), and `#private`
122
+ * members do not cross it — every routed root call then fails with "Cannot
123
+ * read private member #distro from an object whose class did not declare it".
124
+ * The host backend this class extends keeps its own state plain (`config`,
125
+ * `locks`) for the same reason.
70
126
  */
71
- #distro;
127
+ distroBackend;
72
128
  /** The distro knobs, verbatim from the row config. */
73
- #distroConfig;
129
+ distroConfig;
130
+ /**
131
+ * The context this router was constructed with. `this.ctx` inside a method is
132
+ * the CALLER's context (cordis re-binds it for dependency tracing), so the
133
+ * backend's scope must not be derived from it: the first routed call may come
134
+ * from any consumer fiber.
135
+ */
136
+ routeContext;
74
137
  /**
75
138
  * @param ctx - the cordis context.
76
139
  * @param config - the row config, host knobs plus distro knobs.
77
140
  */
78
141
  constructor(ctx, config) {
79
142
  super(ctx, config);
80
- this.#distroConfig = {
143
+ this.routeContext = ctx;
144
+ this.distroConfig = {
81
145
  distro: config.distro ?? "",
82
146
  wslPath: config.wslPath ?? "wsl.exe",
83
147
  cwd: config.distroCwd ?? "",
@@ -86,11 +150,21 @@ export class WslRoutingFileSystem extends LocalFileSystem {
86
150
  maskWindowsDrive: config.maskWindowsDrive ?? false,
87
151
  restrictToDistro: config.restrictToDistro ?? true,
88
152
  watchMaxDepth: config.watchMaxDepth ?? 0,
153
+ // The root router is app-level, not session-owned: its backend must not
154
+ // acquire a session runtime (that would live for the app, not the
155
+ // session). Preset-scoped `wsl-fs` is the owner of the session runtime.
156
+ sessionSandbox: false,
89
157
  };
90
158
  }
91
159
  /** The distro backend for routed targets, built on first use. */
92
160
  distro() {
93
- return (this.#distro ??= new WslFileSystem(this.ctx, this.#distroConfig));
161
+ // The backend is a `Service` that registers itself as `fs`. Constructing it
162
+ // with this router's own context would collide with the router's
163
+ // registration ("service fs has been registered"), so it gets a scope of its
164
+ // own: the backend is used directly through this field, never resolved as
165
+ // the context's `fs`. Isolation re-keys only `fs`; `sandboxPolicy`, which
166
+ // the backend reads, still resolves from the parent scope.
167
+ return (this.distroBackend ??= new WslFileSystem(this.routeContext.isolate("fs"), this.distroConfig));
94
168
  }
95
169
  /**
96
170
  * @param path - the requested path.
@@ -210,14 +284,8 @@ export class WslRoutingFileSystem extends LocalFileSystem {
210
284
  * @param child - the contained candidate.
211
285
  */
212
286
  contains(parent, child) {
213
- if (targetIsDistro(parent.targetKey)) {
214
- // Both targets came from this router's distro resolve, so the plain
215
- // POSIX containment over their process paths is the same check the
216
- // distro substrate performs for its own guarded writes.
217
- const base = this.processPath(parent);
218
- const full = this.processPath(child);
219
- return full === base || full.startsWith(base.endsWith("/") ? base : `${base}/`);
220
- }
287
+ if (targetIsDistro(parent.targetKey))
288
+ return distroContains(parent.targetKey, child.targetKey);
221
289
  return super.contains(parent, child);
222
290
  }
223
291
  /**
package/lib/fsio-agent.js CHANGED
@@ -72,7 +72,7 @@ export function parseStatRecord(stdout) {
72
72
  }
73
73
  const REASON_CODES = {
74
74
  notfound: "FS_NOT_FOUND",
75
- notdir: "FS_NOT_FOUND",
75
+ notdir: "FS_NOT_DIRECTORY",
76
76
  perm: "FS_IO_ERROR",
77
77
  loop: "FS_IO_ERROR",
78
78
  exists: "FS_NOT_OBSERVED",
package/lib/index.d.ts CHANGED
@@ -46,6 +46,9 @@ import { FsError } from "@deepseek-ai/dsh-fs";
46
46
  import { WslSandbox } from "./sandbox.js";
47
47
  import type { WslAgent } from "./agent.js";
48
48
  import { AgentSubstrate } from "./fs-substrate.js";
49
+ import { SessionRuntimes } from "./session-runtime.js";
50
+ import type { AgentBackend } from "./native-agent.js";
51
+ import type { SessionRuntimeEntry } from "./session-runtime.js";
49
52
  import type { Context } from "@deepseek-ai/cordis";
50
53
  import type { FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, FsPathInfo, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from "@deepseek-ai/dsh-fs";
51
54
  import type { SandboxExecutionPolicy, SandboxMode } from "@deepseek-ai/dsh-sandbox";
@@ -125,6 +128,10 @@ export interface WslShellExecutorConfig {
125
128
  };
126
129
  /** Whether execution prefers the resident agent. */
127
130
  agent: boolean;
131
+ /** Whether commands run in one persistent session scope (ADR 0001). */
132
+ sessionSandbox: boolean;
133
+ /** The session runtime's agent backend; `auto` when omitted. */
134
+ agentBackend?: AgentBackend;
128
135
  }
129
136
  /**
130
137
  * The upstream executor's spawn-spec builder, re-opened for this subclass —
@@ -230,6 +237,17 @@ export declare class WslShellExecutor extends LocalBashExecutor {
230
237
  * decoration are identical on both paths.
231
238
  */
232
239
  agent: z<boolean, boolean, "defined">;
240
+ agentBackend: z<"auto" | "native" | "shell", "auto" | "native" | "shell", "defined">;
241
+ /**
242
+ * Run this session's commands in one persistent systemd user scope (ADR
243
+ * 0001): background processes survive the command that started them, `/tmp`
244
+ * is the distro's real `/tmp` under workspace-write, and `ps` sees the
245
+ * session. The executor holds the runtime until the session's scope
246
+ * disposes, then stops it. Requires systemd, a reachable user manager and
247
+ * `Linger`; the preflight refuses with the remedy when one is missing, and
248
+ * only a success is cached. Off by default; requires `agent: true`.
249
+ */
250
+ sessionSandbox: z<boolean, boolean, "defined">;
233
251
  }>>, Schemastery.ObjectT<NoInfer<{
234
252
  /** Distro name; empty means WSL's own default distro. */
235
253
  distro: z<string, string, "defined">;
@@ -294,6 +312,17 @@ export declare class WslShellExecutor extends LocalBashExecutor {
294
312
  * decoration are identical on both paths.
295
313
  */
296
314
  agent: z<boolean, boolean, "defined">;
315
+ agentBackend: z<"auto" | "native" | "shell", "auto" | "native" | "shell", "defined">;
316
+ /**
317
+ * Run this session's commands in one persistent systemd user scope (ADR
318
+ * 0001): background processes survive the command that started them, `/tmp`
319
+ * is the distro's real `/tmp` under workspace-write, and `ps` sees the
320
+ * session. The executor holds the runtime until the session's scope
321
+ * disposes, then stops it. Requires systemd, a reachable user manager and
322
+ * `Linger`; the preflight refuses with the remedy when one is missing, and
323
+ * only a success is cached. Off by default; requires `agent: true`.
324
+ */
325
+ sessionSandbox: z<boolean, boolean, "defined">;
297
326
  }>>, "plain">;
298
327
  /** Cached default distro, resolved once from `wsl.exe -l -q`. */
299
328
  resolvedDistro: string;
@@ -305,6 +334,14 @@ export declare class WslShellExecutor extends LocalBashExecutor {
305
334
  mode: SandboxMode | undefined;
306
335
  /** The distro-side confinement, or nothing when the operator opted out. */
307
336
  sandbox: InstanceType<typeof WslSandbox> | undefined;
337
+ /** The session-runtime registry; tests substitute it. */
338
+ sessionRuntimes: SessionRuntimes;
339
+ /** The runtimes this executor holds, by registry key. */
340
+ sessionRuntimeEntries: Map<string, SessionRuntimeEntry>;
341
+ /** The preflight's host runner; tests substitute it. */
342
+ sessionScopeProbe: (argv: string[]) => Promise<string>;
343
+ /** The last preflight verdict; only a success is cached. */
344
+ sessionRuntimePreflight: "unknown" | "ready";
308
345
  /**
309
346
  * Per-process confinement facts retained until settlement. Overlapping calls
310
347
  * can carry different policies, so a shared latest-wrap value would classify
@@ -358,6 +395,41 @@ export declare class WslShellExecutor extends LocalBashExecutor {
358
395
  * @returns the shared agent.
359
396
  */
360
397
  executionAgent(distro: string): WslAgent;
398
+ /**
399
+ * The persistent session runtime for one policy, when `sessionSandbox` is
400
+ * on. The executor holds one reference per `(distro, workspace, policy)` and
401
+ * releases them when the session's scope disposes.
402
+ *
403
+ * @param policy - the resolved file-effect policy, when the executor has one.
404
+ * @param resolved - the resolved spec, for the fallback workspace.
405
+ * @param distro - the resolved distro name.
406
+ * @returns the runtime entry, or undefined when the mode is off.
407
+ */
408
+ sessionRuntimeFor(policy: {
409
+ mode: string;
410
+ workspaceRoot: string;
411
+ } | undefined, resolved: WslExecSpec, distro: string): SessionRuntimeEntry | undefined;
412
+ /**
413
+ * Run the session runtime's preflight once per executor. A failure is a
414
+ * configuration verdict, not an agent outage: it is raised as an Error with
415
+ * the remedy, so the command fails closed instead of falling back to the
416
+ * per-command sandbox. Only success is cached, so a user who follows the
417
+ * remedy (`loginctl enable-linger`, `[boot] systemd=true`) does not need to
418
+ * restart the app.
419
+ *
420
+ * @param distro - the resolved distro name.
421
+ * @returns resolved once the platform can host a session runtime.
422
+ * @throws {Error} with the remedy when it cannot.
423
+ */
424
+ ensureSessionRuntimeUsable(distro: string): Promise<void>;
425
+ /**
426
+ * Release every runtime this executor holds, stopping each scope. Called
427
+ * from the session's dispose hook; public so tests and shutdown paths can
428
+ * await it.
429
+ *
430
+ * @returns resolved once every runtime is torn down.
431
+ */
432
+ disposeSessionRuntimes(): Promise<void>;
361
433
  /**
362
434
  * The argv that runs INSIDE the distro for one spec: the resolved shell, its
363
435
  * login/command flags, and the command string. This is the argv a
@@ -597,6 +669,8 @@ export interface WslFileSystemConfig {
597
669
  diffBasisMaxBytes?: number;
598
670
  /** Whether mutations are fenced by the policy. */
599
671
  sandbox: boolean;
672
+ /** Whether confined mutations run in the session runtime (ADR 0001). */
673
+ sessionSandbox: boolean;
600
674
  /** Whether confined profiles mask `/mnt`. */
601
675
  maskWindowsDrive: boolean;
602
676
  /** Whether paths leaving the distro are refused. */
@@ -633,6 +707,17 @@ export declare class WslFileSystem extends LocalFileSystem {
633
707
  * operations are unconfined.
634
708
  */
635
709
  sandbox: z<boolean, boolean, "defined">;
710
+ /**
711
+ * Run confined mutations in the session's systemd user scope — the same
712
+ * runtime the shell executor acquires for the same (distro, workspace,
713
+ * policy) — instead of a per-policy confined resident. The kernel
714
+ * enforcement is the runtime's mount profile, so commands and writes see
715
+ * the same `/tmp` and workspace; the scope stops when the session's
716
+ * providers release it. Requires systemd + `Linger`, like `wsl-shell`'s
717
+ * `sessionSandbox`; the preflight refuses with the remedy. Off for direct
718
+ * constructions; on in the shipped preset.
719
+ */
720
+ sessionSandbox: z<boolean, boolean, "defined">;
636
721
  /**
637
722
  * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
638
723
  * Windows drive from confined commands — same meaning and same honest
@@ -694,6 +779,17 @@ export declare class WslFileSystem extends LocalFileSystem {
694
779
  * operations are unconfined.
695
780
  */
696
781
  sandbox: z<boolean, boolean, "defined">;
782
+ /**
783
+ * Run confined mutations in the session's systemd user scope — the same
784
+ * runtime the shell executor acquires for the same (distro, workspace,
785
+ * policy) — instead of a per-policy confined resident. The kernel
786
+ * enforcement is the runtime's mount profile, so commands and writes see
787
+ * the same `/tmp` and workspace; the scope stops when the session's
788
+ * providers release it. Requires systemd + `Linger`, like `wsl-shell`'s
789
+ * `sessionSandbox`; the preflight refuses with the remedy. Off for direct
790
+ * constructions; on in the shipped preset.
791
+ */
792
+ sessionSandbox: z<boolean, boolean, "defined">;
697
793
  /**
698
794
  * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
699
795
  * Windows drive from confined commands — same meaning and same honest
@@ -747,6 +843,14 @@ export declare class WslFileSystem extends LocalFileSystem {
747
843
  sandbox: InstanceType<typeof WslSandbox>;
748
844
  /** The resident agent serving publication (see `publicationAgent`). */
749
845
  agentInstance: WslAgent | undefined;
846
+ /** The session-runtime registry; tests substitute it. */
847
+ sessionRuntimes: SessionRuntimes;
848
+ /** The runtimes this provider holds, by registry key. */
849
+ sessionRuntimeEntries: Map<string, SessionRuntimeEntry>;
850
+ /** The preflight's host runner; tests substitute it. */
851
+ sessionScopeProbe: (argv: string[]) => Promise<string>;
852
+ /** The last preflight verdict; only a success is cached. */
853
+ sessionRuntimePreflight: "unknown" | "ready";
750
854
  /**
751
855
  * @param ctx - the composition context.
752
856
  * @param config - the validated plugin config.
@@ -776,6 +880,36 @@ export declare class WslFileSystem extends LocalFileSystem {
776
880
  * @returns the substrate.
777
881
  */
778
882
  agentSubstrate(): Promise<AgentSubstrate>;
883
+ /**
884
+ * The persistent session runtime for one confined mutation, when
885
+ * `sessionSandbox` is on. The key is the shell executor's key for the same
886
+ * session/policy, so the write and the commands share one agent, one mount
887
+ * view and one scope; the provider holds a reference until the session ends.
888
+ *
889
+ * @param policy - the resolved confined policy.
890
+ * @param distro - the resolved distro name.
891
+ * @returns the runtime entry, or undefined when the mode is off.
892
+ */
893
+ sessionRuntimeFor(policy: {
894
+ mode: string;
895
+ workspaceRoot: string;
896
+ }, distro: string): SessionRuntimeEntry | undefined;
897
+ /**
898
+ * Run the session runtime's preflight once per provider. Only success is
899
+ * cached, so following the remedy does not need an app restart.
900
+ *
901
+ * @param distro - the resolved distro name.
902
+ * @returns resolved once the platform can host the runtime.
903
+ * @throws {Error} with the remedy when it cannot.
904
+ */
905
+ ensureSessionRuntimeUsable(distro: string): Promise<void>;
906
+ /**
907
+ * Release every runtime this provider holds. Called from the preset's
908
+ * dispose effect; public so tests and shutdown paths can await it.
909
+ *
910
+ * @returns resolved once every reference is released.
911
+ */
912
+ disposeSessionRuntimes(): Promise<void>;
779
913
  /**
780
914
  * The agent a MUTATION runs on under this resolved policy — stage-two
781
915
  * routing. A confined policy addresses the confined resident whose bwrap
package/lib/index.js CHANGED
@@ -55,9 +55,11 @@ import { sharedAgent } from "./agent-shared.js";
55
55
  import { AgentSubstrate } from "./fs-substrate.js";
56
56
  import { FsCodedError } from "./fsio-text.js";
57
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";
58
60
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
59
61
  // can use them without pulling this file's executor and filesystem peers in.
60
- import { defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
62
+ import { defaultDistro, defaultShell, linuxHomePath, runCapture, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
61
63
  /**
62
64
  * Every model shell call gets a rebuilt managed `DSH_*` namespace (see
63
65
  * `dsh-shell-env` / `ctx.shellEnv`). Those names exist only in the Windows-side
@@ -82,6 +84,14 @@ const SAFE_FORWARD = ["NO_COLOR", "TERM", "PAGER", "GIT_PAGER", "LANG", "LC_ALL"
82
84
  * @param config - a provider's config.
83
85
  * @returns the configured directory, trimmed, or `""`.
84
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
+ */
85
95
  function configuredCwd(config) {
86
96
  return String(config.cwd?.get?.() ?? "").trim();
87
97
  }
@@ -213,6 +223,17 @@ export class WslShellExecutor extends LocalBashExecutor {
213
223
  * decoration are identical on both paths.
214
224
  */
215
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),
216
237
  });
217
238
  /**
218
239
  * @param ctx - the composition context.
@@ -220,12 +241,20 @@ export class WslShellExecutor extends LocalBashExecutor {
220
241
  */
221
242
  constructor(ctx, config) {
222
243
  super(ctx, config);
244
+ configureAgentBackend(config.agentBackend ?? "auto");
223
245
  this.resolvedDistro = config.distro;
224
246
  this.resolvedShell = config.shell;
225
247
  this.resolvedHome = undefined;
226
248
  this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
227
249
  this.sandbox = config.sandbox ? new WslSandbox({ wslPath: config.wslPath }) : undefined;
228
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");
229
258
  }
230
259
  /**
231
260
  * The mode this executor confines with, or `undefined` when it does not
@@ -302,6 +331,71 @@ export class WslShellExecutor extends LocalBashExecutor {
302
331
  executionAgent(distro) {
303
332
  return sharedAgent(distro);
304
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
+ }
305
399
  /**
306
400
  * The argv that runs INSIDE the distro for one spec: the resolved shell, its
307
401
  * login/command flags, and the command string. This is the argv a
@@ -604,14 +698,25 @@ export class WslShellExecutor extends LocalBashExecutor {
604
698
  ? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
605
699
  : undefined;
606
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);
607
712
  const execution = agentExecutionHandle({
608
- agent: await this.executionAgent(distro),
713
+ agent,
609
714
  // The one-shot path converts the spec's workdir into the distro's
610
715
  // coordinate system inside argv(); the agent carries the cwd as a frame
611
716
  // field, so the SAME conversion happens here — a raw UNC must never
612
717
  // reach `chdir` (that is the bug this line fixes).
613
718
  cwd: toLinuxPath(resolved.workdir, { distro }),
614
- argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
719
+ argv,
615
720
  timeoutMs: resolved.timeoutMs,
616
721
  // The agent enforces the collect budget distro-side (the EXEC frame
617
722
  // carries it); `maxSpillBytes` stays one-shot-only — the agent path has
@@ -780,6 +885,17 @@ export class WslFileSystem extends LocalFileSystem {
780
885
  * operations are unconfined.
781
886
  */
782
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),
783
899
  /**
784
900
  * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
785
901
  * Windows drive from confined commands — same meaning and same honest
@@ -839,6 +955,13 @@ export class WslFileSystem extends LocalFileSystem {
839
955
  this.agentSubstrateInstance = undefined;
840
956
  this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
841
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");
842
965
  }
843
966
  /**
844
967
  * The mode this backend fences mutations with, or `undefined` when the
@@ -883,6 +1006,65 @@ export class WslFileSystem extends LocalFileSystem {
883
1006
  }
884
1007
  return this.agentSubstrateInstance;
885
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
+ }
886
1068
  /**
887
1069
  * The agent a MUTATION runs on under this resolved policy — stage-two
888
1070
  * routing. A confined policy addresses the confined resident whose bwrap
@@ -912,6 +1094,12 @@ export class WslFileSystem extends LocalFileSystem {
912
1094
  // this path has the same failure, so it carries the same text.
913
1095
  throw new SandboxUnavailableError(confinedPolicy.mode, bwrapFailure(config.wslPath, distro) ?? missingBwrapRemedy(distro));
914
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;
1102
+ }
915
1103
  return confinedAgent({ distro, wslPath: config.wslPath, policy: confinedPolicy, maskWindowsDrive: config.maskWindowsDrive });
916
1104
  }
917
1105
  /**