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/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