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.
- package/CHANGELOG.md +417 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +84 -68
- package/SUPPORT.md +3 -3
- package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
- package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
- package/agent/wsl-agent.sh +83 -16
- package/cordis.patch.yml +41 -35
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/CONFIGURATION.md +7 -4
- package/docs/LIMITATIONS.md +53 -18
- package/docs/PARITY.md +3 -1
- package/docs/PEER-PARITY.md +18 -11
- package/docs/README.zh.md +97 -74
- package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
- package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
- package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
- package/lib/agent-protocol.d.ts +10 -4
- package/lib/agent-protocol.js +10 -3
- package/lib/agent.d.ts +84 -0
- package/lib/agent.js +167 -15
- package/lib/bwrap.d.ts +8 -0
- package/lib/bwrap.js +4 -1
- package/lib/fs-routing.d.ts +40 -4
- package/lib/fs-routing.js +80 -12
- package/lib/fsio-agent.js +1 -1
- package/lib/index.d.ts +134 -0
- package/lib/index.js +191 -3
- package/lib/native-agent.d.ts +124 -0
- package/lib/native-agent.js +198 -0
- package/lib/ports.d.ts +13 -2
- package/lib/ports.js +18 -5
- package/lib/preflight.js +24 -1
- package/lib/session-runtime.d.ts +161 -0
- package/lib/session-runtime.js +220 -0
- package/lib/session-scope.d.ts +124 -0
- package/lib/session-scope.js +191 -0
- package/lib/shell-env.js +10 -3
- package/lib/watcher.d.ts +25 -12
- package/lib/watcher.js +120 -32
- package/lib/workspace-files-route.d.ts +8 -4
- package/lib/workspace-files-route.js +42 -14
- package/lib/workspace-files-wsl.js +25 -8
- 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
|
-
|
|
127
|
+
distroBackend;
|
|
72
128
|
/** The distro knobs, verbatim from the row config. */
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
|
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
|
/**
|