dsh-plugin-wsl-env 0.1.10 → 0.3.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 +268 -0
- package/README.md +21 -2
- package/agent/wsl-agent.sh +498 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +12 -1
- package/docs/LIMITATIONS.md +70 -5
- package/docs/PARITY.md +70 -0
- package/docs/PEER-PARITY.md +60 -0
- package/docs/README.zh.md +31 -20
- package/docs/UPSTREAM-FSIO-EXPORT.md +87 -0
- package/lib/agent-confined.js +81 -0
- package/lib/agent-errors.js +33 -0
- package/lib/agent-exec.js +204 -0
- package/lib/agent-protocol.js +174 -0
- package/lib/agent-shared.js +32 -0
- package/lib/agent.js +564 -0
- package/lib/bwrap.js +74 -0
- package/lib/fs-publish.js +42 -0
- package/lib/fs-substrate.js +285 -0
- package/lib/fsio-agent.js +481 -0
- package/lib/fsio-text.js +153 -0
- package/lib/index.js +430 -12
- package/lib/ports.js +61 -0
- package/lib/sandbox.js +35 -28
- package/lib/shell-env.js +28 -2
- package/lib/subprocess.js +15 -0
- package/lib/terminal-route.js +31 -0
- package/lib/watcher.js +127 -0
- package/package.json +13 -4
package/lib/index.js
CHANGED
|
@@ -37,13 +37,23 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { rename } from "node:fs/promises";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
40
41
|
import z from "@deepseek-ai/schemastery";
|
|
41
42
|
import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
|
|
42
43
|
import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
|
|
43
44
|
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
44
45
|
import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
|
|
45
|
-
import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix } from "./paths.js";
|
|
46
|
+
import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
46
47
|
import { WslSandbox } from "./sandbox.js";
|
|
48
|
+
import { armDistroWatcher } from "./watcher.js";
|
|
49
|
+
import { WslAgent } from "./agent.js";
|
|
50
|
+
import { AgentUnavailableError } from "./agent-errors.js";
|
|
51
|
+
import { publicationArgv } from "./fs-publish.js";
|
|
52
|
+
import { agentExecutionHandle } from "./agent-exec.js";
|
|
53
|
+
import { sharedAgent } from "./agent-shared.js";
|
|
54
|
+
import { AgentSubstrate } from "./fs-substrate.js";
|
|
55
|
+
import { FsCodedError } from "./fsio-text.js";
|
|
56
|
+
import { confinedAgent } from "./agent-confined.js";
|
|
47
57
|
// `runCapture` and distro discovery live in ./wsl.js so the directory picker
|
|
48
58
|
// can use them without pulling this file's executor and filesystem peers in.
|
|
49
59
|
import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
|
|
@@ -165,6 +175,20 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
165
175
|
* unconfined and `sandboxMode` is `undefined`, so the model is told so.
|
|
166
176
|
*/
|
|
167
177
|
sandbox: z.boolean().default(true),
|
|
178
|
+
/**
|
|
179
|
+
* Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
|
|
180
|
+
* Windows drive from confined commands: its files cannot be read or
|
|
181
|
+
* exfiltrated and its executables cannot be launched through interop —
|
|
182
|
+
* the hole that lets a "sandboxed" command reach your full Windows token.
|
|
183
|
+
* A narrowing, not a closure (a command can still write an executable
|
|
184
|
+
* into the workspace and run it — binfmt dispatches on content, not
|
|
185
|
+
* location), so enforcement stays `partial` and the sandbox fact reports
|
|
186
|
+
* `windowsDrive: "masked"`. Costs: confined commands cannot read or touch
|
|
187
|
+
* anything under `/mnt`, and tools that probe the drive degrade. The
|
|
188
|
+
* complete closure is distro-level: `[interop] enabled=false` in
|
|
189
|
+
* `wsl.conf`.
|
|
190
|
+
*/
|
|
191
|
+
maskWindowsDrive: z.boolean().default(false),
|
|
168
192
|
/**
|
|
169
193
|
* Default workdir for a request that names none; accepts `/home/andy/proj`,
|
|
170
194
|
* a UNC path, or `C:\...`. Empty — the default — means the distro user's
|
|
@@ -180,6 +204,14 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
180
204
|
maxOutputBytes: z.number().default(64e3).volatile(),
|
|
181
205
|
maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
|
|
182
206
|
graceMs: z.number().default(3e3).volatile(),
|
|
207
|
+
/**
|
|
208
|
+
* Run commands through the resident agent when it is up (one long-lived
|
|
209
|
+
* in-distro process; in-distro timeout semantics), falling back to the
|
|
210
|
+
* one-shot `wsl.exe` path whenever the agent is out. `false` never uses
|
|
211
|
+
* the agent. The confinement, the output budgets' shapes and the result
|
|
212
|
+
* decoration are identical on both paths.
|
|
213
|
+
*/
|
|
214
|
+
agent: z.boolean().default(true),
|
|
183
215
|
});
|
|
184
216
|
|
|
185
217
|
constructor(ctx, config) {
|
|
@@ -249,6 +281,15 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
249
281
|
return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
|
|
250
282
|
}
|
|
251
283
|
|
|
284
|
+
/**
|
|
285
|
+
* The shared resident agent serving this executor's distro.
|
|
286
|
+
* @param distro - the resolved distro name.
|
|
287
|
+
* @returns {WslAgent} the shared agent.
|
|
288
|
+
*/
|
|
289
|
+
executionAgent(distro) {
|
|
290
|
+
return sharedAgent(distro);
|
|
291
|
+
}
|
|
292
|
+
|
|
252
293
|
/**
|
|
253
294
|
* The argv that runs INSIDE the distro for one spec: the resolved shell, its
|
|
254
295
|
* login/command flags, and the command string. This is the argv a
|
|
@@ -423,6 +464,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
423
464
|
mode: facts.mode,
|
|
424
465
|
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
|
425
466
|
enforcement: facts.enforcement,
|
|
467
|
+
...(facts.windowsDrive ? { windowsDrive: facts.windowsDrive } : {}),
|
|
426
468
|
...(runnerFailed ? { runnerFailed } : {}),
|
|
427
469
|
};
|
|
428
470
|
}
|
|
@@ -492,6 +534,94 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
492
534
|
const distro = await this.distro();
|
|
493
535
|
const shell = await this.shell();
|
|
494
536
|
const resolved = await this.withDefaultWorkdir(spec);
|
|
537
|
+
if (this.config.agent) {
|
|
538
|
+
try {
|
|
539
|
+
return await this.executeViaAgent(resolved, distro, shell);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
// Only an out-of-service agent falls back; anything else (a cwd
|
|
542
|
+
// refusal, a sandbox refusal) is the command's own verdict.
|
|
543
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return this.executeOneShot(resolved, distro, shell);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The agent-backed execution path: one long-lived in-distro process, the
|
|
551
|
+
* confinement argv unchanged, the handle shape unchanged. Any
|
|
552
|
+
* {@link AgentUnavailableError} propagates for the caller's fallback.
|
|
553
|
+
* @param resolved - the resolved spec from {@link WslShellExecutor.execute}.
|
|
554
|
+
* @param distro - the resolved distro name.
|
|
555
|
+
* @param shell - the resolved distro shell.
|
|
556
|
+
* @returns the decorated execution result.
|
|
557
|
+
*/
|
|
558
|
+
async executeViaAgent(resolved, distro, shell) {
|
|
559
|
+
const policy = resolved.sandboxPolicy;
|
|
560
|
+
const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
|
|
561
|
+
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro })
|
|
562
|
+
: undefined;
|
|
563
|
+
const mode = policy?.mode;
|
|
564
|
+
const execution = agentExecutionHandle({
|
|
565
|
+
agent: await this.executionAgent(distro),
|
|
566
|
+
// The one-shot path converts the spec's workdir into the distro's
|
|
567
|
+
// coordinate system inside argv(); the agent carries the cwd as a frame
|
|
568
|
+
// field, so the SAME conversion happens here — a raw UNC must never
|
|
569
|
+
// reach `chdir` (that is the bug this line fixes).
|
|
570
|
+
cwd: toLinuxPath(resolved.workdir, { distro }),
|
|
571
|
+
argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
|
|
572
|
+
timeoutMs: resolved.timeoutMs,
|
|
573
|
+
// The agent enforces the collect budget distro-side (the EXEC frame
|
|
574
|
+
// carries it); `maxSpillBytes` stays one-shot-only — the agent path has
|
|
575
|
+
// no spill files by design, its capture is bounded instead.
|
|
576
|
+
maxOutputBytes: this.config.maxOutputBytes,
|
|
577
|
+
signal: resolved.signal,
|
|
578
|
+
onStarted: confined !== undefined
|
|
579
|
+
? (proc) => {
|
|
580
|
+
this.processFacts.set(proc, {
|
|
581
|
+
mode,
|
|
582
|
+
enforcement: confined.enforcement,
|
|
583
|
+
denialSignatures: confined.denialSignatures,
|
|
584
|
+
runnerFailureRules: confined.runnerFailureRules,
|
|
585
|
+
runnerProgram: confined.argv[0],
|
|
586
|
+
workdir: resolved.workdir,
|
|
587
|
+
windowsDrive: confined.windowsDrive,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
: undefined,
|
|
591
|
+
});
|
|
592
|
+
return WslShellExecutor.decorateResult(execution, (result) => {
|
|
593
|
+
this.throwIfDistroUnavailable(result, distro);
|
|
594
|
+
this.throwIfWorkdirMissing(result, resolved.workdir);
|
|
595
|
+
if (this.sandbox === undefined) return result;
|
|
596
|
+
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
|
|
597
|
+
const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
|
|
598
|
+
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
|
|
599
|
+
if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
|
|
600
|
+
return {
|
|
601
|
+
...result,
|
|
602
|
+
sandbox: {
|
|
603
|
+
mode,
|
|
604
|
+
denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
|
|
605
|
+
enforcement,
|
|
606
|
+
windowsDrive,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}, (error) => {
|
|
610
|
+
if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
|
|
611
|
+
throw error;
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The pre-agent execution path, verbatim: one `wsl.exe` per command through
|
|
617
|
+
* the inherited `executeArgv` machinery. Kept as the permanent fallback for
|
|
618
|
+
* an out-of-service agent and as the `agent: false` behaviour.
|
|
619
|
+
* @param resolved - the resolved spec.
|
|
620
|
+
* @param distro - the resolved distro name.
|
|
621
|
+
* @param shell - the resolved distro shell.
|
|
622
|
+
* @returns the decorated execution result.
|
|
623
|
+
*/
|
|
624
|
+
async executeOneShot(resolved, distro, shell) {
|
|
495
625
|
const policy = resolved.sandboxPolicy;
|
|
496
626
|
if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
|
|
497
627
|
// `executeArgv` resolves to the execution handle, so it has to be awaited
|
|
@@ -529,6 +659,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
529
659
|
runnerFailureRules: confined.runnerFailureRules,
|
|
530
660
|
runnerProgram: confined.argv[0],
|
|
531
661
|
workdir: resolved.workdir,
|
|
662
|
+
windowsDrive: confined.windowsDrive,
|
|
532
663
|
});
|
|
533
664
|
},
|
|
534
665
|
);
|
|
@@ -536,7 +667,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
536
667
|
this.throwIfDistroUnavailable(result, distro);
|
|
537
668
|
this.throwIfWorkdirMissing(result, resolved.workdir);
|
|
538
669
|
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
|
|
539
|
-
const { enforcement, denialSignatures, runnerFailureRules } = confined;
|
|
670
|
+
const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
|
|
540
671
|
// Runner failure outranks denial because the command did not run at all.
|
|
541
672
|
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
|
|
542
673
|
if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
|
|
@@ -546,6 +677,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
546
677
|
mode,
|
|
547
678
|
denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
|
|
548
679
|
enforcement,
|
|
680
|
+
windowsDrive,
|
|
549
681
|
},
|
|
550
682
|
};
|
|
551
683
|
}, (error) => {
|
|
@@ -610,6 +742,14 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
610
742
|
* operations are unconfined.
|
|
611
743
|
*/
|
|
612
744
|
sandbox: z.boolean().default(true),
|
|
745
|
+
/**
|
|
746
|
+
* Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
|
|
747
|
+
* Windows drive from confined commands — same meaning and same honest
|
|
748
|
+
* limits as the `wsl-shell` key of the same name (its doc carries the
|
|
749
|
+
* full story). Confined fs mutations publish through the masked resident;
|
|
750
|
+
* reads and the share substrate are unaffected.
|
|
751
|
+
*/
|
|
752
|
+
maskWindowsDrive: z.boolean().default(false),
|
|
613
753
|
/**
|
|
614
754
|
* Refuse any path that leaves the pinned distro. A WSL filesystem is a
|
|
615
755
|
* whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
|
|
@@ -626,6 +766,28 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
626
766
|
* distinct failing path, cached.
|
|
627
767
|
*/
|
|
628
768
|
resolveSymlinks: z.boolean().default(true),
|
|
769
|
+
/**
|
|
770
|
+
* Run the in-distro side of publication (mode copy + atomic rename) through
|
|
771
|
+
* the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
|
|
772
|
+
* `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
|
|
773
|
+
* legacy two-step path for that call and, after the agent's own rebuild
|
|
774
|
+
* budget is spent, permanently — this flag never changes what is published,
|
|
775
|
+
* only how many round trips it costs.
|
|
776
|
+
*/
|
|
777
|
+
agent: z.boolean().default(true),
|
|
778
|
+
/**
|
|
779
|
+
* Which I/O substrate serves the file tools. `"agent"` (the default) —
|
|
780
|
+
* the resident in-distro agent (`lib/fs-substrate.js`): reads, writes,
|
|
781
|
+
* identities and watches run where the files live, symlinks and mode bits
|
|
782
|
+
* are native, and the mutation guard survives to the write's no-replace
|
|
783
|
+
* publication, kernel-enforced by the confined resident when the policy is
|
|
784
|
+
* confined. `"share"` — the opt-out: the Windows-side host filesystem
|
|
785
|
+
* stack over the distro's 9p share, with the symlink-retry and publication
|
|
786
|
+
* hooks that compensate for what the share cannot do. Both substrates
|
|
787
|
+
* fence writes with the same host-side policy check; neither changes
|
|
788
|
+
* `sandboxMode`'s honesty.
|
|
789
|
+
*/
|
|
790
|
+
substrate: z.union(["share", "agent"]).default("agent"),
|
|
629
791
|
});
|
|
630
792
|
|
|
631
793
|
constructor(ctx, config) {
|
|
@@ -637,6 +799,10 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
637
799
|
this.defaultMode = ctx.sandboxPolicy.defaultMode;
|
|
638
800
|
/** targetKey -> canonical replacement target, for symlinked paths. */
|
|
639
801
|
this.canonicalTargets = new Map();
|
|
802
|
+
/** The lazy agent-backed substrate, built on first use (see `agentSubstrate`). */
|
|
803
|
+
this.agentSubstrateInstance = undefined;
|
|
804
|
+
/** The distro-side bwrap usability probe, shared with the confined agents. */
|
|
805
|
+
this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
|
|
640
806
|
// `dsh-fs-local` takes a Windows branch whenever the destination already
|
|
641
807
|
// exists — which is how it knows to preserve the replaced file's security
|
|
642
808
|
// descriptor, and what makes every write to an existing file fail here:
|
|
@@ -692,11 +858,117 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
692
858
|
const source = uncToPosix(replaced);
|
|
693
859
|
const staged = uncToPosix(replacement);
|
|
694
860
|
if (source !== undefined && staged !== undefined) {
|
|
861
|
+
if (this.config.agent) {
|
|
862
|
+
try {
|
|
863
|
+
// One in-distro round trip: mode copy + atomic rename. The staged
|
|
864
|
+
// temp is on the share, but the agent reaches it by its Linux path,
|
|
865
|
+
// where the kernel does the rename — no 9p publication involved.
|
|
866
|
+
const agent = await this.publicationAgent();
|
|
867
|
+
const result = await agent.exec({
|
|
868
|
+
cwd: "/",
|
|
869
|
+
argv: publicationArgv(source.linuxPath, staged.linuxPath),
|
|
870
|
+
timeoutMs: 30000,
|
|
871
|
+
});
|
|
872
|
+
if (result.exitCode === 0) return;
|
|
873
|
+
// A nonzero from the script is a real publication failure (chmod or
|
|
874
|
+
// rename); report it rather than retrying through the legacy path,
|
|
875
|
+
// which would fail the same way but slower.
|
|
876
|
+
throw new FsError(`publishing "${replaced}" failed inside the distro (exit ${result.exitCode})`, "FS_IO_ERROR");
|
|
877
|
+
} catch (error) {
|
|
878
|
+
// The agent being out is the ONLY thing the legacy path exists for.
|
|
879
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
695
882
|
await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
|
|
696
883
|
}
|
|
697
884
|
await rename(replacement, replaced);
|
|
698
885
|
}
|
|
699
886
|
|
|
887
|
+
/**
|
|
888
|
+
* The resident agent serving this filesystem's in-distro side work, created
|
|
889
|
+
* on first use and kept for the provider's lifetime (its own idle timer
|
|
890
|
+
* retires the process; a later call starts a fresh one).
|
|
891
|
+
* @returns {Promise<WslAgent>} the agent for the pinned distro.
|
|
892
|
+
*/
|
|
893
|
+
async publicationAgent() {
|
|
894
|
+
if (this.agentInstance === undefined) {
|
|
895
|
+
this.agentInstance = sharedAgent(await this.distro());
|
|
896
|
+
}
|
|
897
|
+
return this.agentInstance;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The agent-backed substrate for `substrate: "agent"`, built on first use
|
|
902
|
+
* over the same resident agent the publication path shares.
|
|
903
|
+
* @returns {Promise<AgentSubstrate|undefined>} the substrate, or undefined
|
|
904
|
+
* when the share backend is configured.
|
|
905
|
+
*/
|
|
906
|
+
async agentSubstrate() {
|
|
907
|
+
if (this.config.substrate !== "agent") return undefined;
|
|
908
|
+
if (this.agentSubstrateInstance === undefined) {
|
|
909
|
+
this.agentSubstrateInstance = new AgentSubstrate({
|
|
910
|
+
agent: await this.publicationAgent(),
|
|
911
|
+
distro: await this.distro(),
|
|
912
|
+
diffBasisMaxBytes: this.config.diffBasisMaxBytes,
|
|
913
|
+
agentFor: (policy) => this.mutationAgent(policy),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
return this.agentSubstrateInstance;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* The agent a MUTATION runs on under this resolved policy — stage-two
|
|
921
|
+
* routing. A confined policy addresses the confined resident whose bwrap
|
|
922
|
+
* profile binds exactly what the mode grants, so the kernel enforces what
|
|
923
|
+
* the check decided; an escalated write (`danger-full-access`) or a disabled
|
|
924
|
+
* sandbox addresses the plain resident, which is what those mean. A distro
|
|
925
|
+
* without a usable bwrap refuses confined mutations instead of downgrading
|
|
926
|
+
* them — the same closed failure the command path has.
|
|
927
|
+
* @param {{mode: string, workspaceRoot: string}|undefined} policy - the
|
|
928
|
+
* resolved file-effect policy, or undefined when the caller pre-checked.
|
|
929
|
+
* @returns {Promise<WslAgent>} the agent to publish through.
|
|
930
|
+
*/
|
|
931
|
+
async mutationAgent(policy) {
|
|
932
|
+
if (!policy || !this.config.sandbox || policy.mode === "danger-full-access") {
|
|
933
|
+
return this.publicationAgent();
|
|
934
|
+
}
|
|
935
|
+
const distro = await this.distro();
|
|
936
|
+
if (!(await this.sandbox.usable(distro))) {
|
|
937
|
+
throw new SandboxUnavailableError(
|
|
938
|
+
policy.mode,
|
|
939
|
+
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
940
|
+
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
941
|
+
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
return confinedAgent({ distro, wslPath: this.config.wslPath, policy, maskWindowsDrive: this.config.maskWindowsDrive });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Map a substrate error onto the peer's `FsError`, so the tool layer reads
|
|
949
|
+
* the same refusal dialect on both substrates. An agent that is out becomes
|
|
950
|
+
* an I/O error naming the cause — the share backend's "distro is gone" case
|
|
951
|
+
* is the same availability, but the message says which substrate failed.
|
|
952
|
+
* @param error - what the substrate raised.
|
|
953
|
+
* @returns {FsError} the error to surface.
|
|
954
|
+
*/
|
|
955
|
+
peerError(error) {
|
|
956
|
+
if (error instanceof FsCodedError) return new FsError(error.message, error.code, { cause: error });
|
|
957
|
+
if (error instanceof SandboxUnavailableError) return new FsError(error.message, "FS_IO_ERROR", { cause: error });
|
|
958
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
959
|
+
return new FsError(`the distro file substrate is unavailable: ${message}`, "FS_IO_ERROR", { cause: error instanceof Error ? error : undefined });
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* One resolved target's Linux path — the substrate's coordinate — falling
|
|
964
|
+
* back to the display spelling when the identity is not a distro share path.
|
|
965
|
+
* @param target - the resolved target.
|
|
966
|
+
* @returns the absolute Linux path.
|
|
967
|
+
*/
|
|
968
|
+
linuxOf(target) {
|
|
969
|
+
return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
|
|
970
|
+
}
|
|
971
|
+
|
|
700
972
|
/**
|
|
701
973
|
* The pinned distro, resolving WSL's default on first use.
|
|
702
974
|
* @returns the distro name.
|
|
@@ -743,8 +1015,19 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
743
1015
|
* @returns the resolved `FsTarget`.
|
|
744
1016
|
*/
|
|
745
1017
|
async resolve(path, opts) {
|
|
1018
|
+
const world = await this.worldPath(path, opts?.cwd);
|
|
1019
|
+
const sub = await this.agentSubstrate();
|
|
1020
|
+
if (sub !== undefined) {
|
|
1021
|
+
const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
|
|
1022
|
+
if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
|
|
1023
|
+
try {
|
|
1024
|
+
return await sub.resolve(linux, opts?.signal);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
throw this.peerError(error);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
746
1029
|
const distro = await this.distro();
|
|
747
|
-
const target = await super.resolve(
|
|
1030
|
+
const target = await super.resolve(world, opts);
|
|
748
1031
|
return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
|
|
749
1032
|
}
|
|
750
1033
|
|
|
@@ -796,7 +1079,23 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
796
1079
|
if (cached !== undefined) return cached;
|
|
797
1080
|
const parsed = uncToPosix(target.targetKey);
|
|
798
1081
|
if (parsed === undefined) return undefined;
|
|
799
|
-
|
|
1082
|
+
// The agent serves the canonical path when it is up (no extra `wsl.exe`
|
|
1083
|
+
// per distinct failing path); the one-shot helper is the fallback and the
|
|
1084
|
+
// `agent: false` path.
|
|
1085
|
+
let canonical;
|
|
1086
|
+
if (this.config.agent) {
|
|
1087
|
+
try {
|
|
1088
|
+
const agent = await this.publicationAgent();
|
|
1089
|
+
const result = await agent.exec({ cwd: "/", argv: ["readlink", "-f", parsed.linuxPath], timeoutMs: 10000, signal });
|
|
1090
|
+
const resolved = result.stdout.toString("utf8").trim();
|
|
1091
|
+
canonical = resolved.startsWith("/") ? resolved : undefined;
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (canonical === undefined) {
|
|
1097
|
+
canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
|
|
1098
|
+
}
|
|
800
1099
|
if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
|
|
801
1100
|
const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
|
|
802
1101
|
this.canonicalTargets.set(target.targetKey, replacement);
|
|
@@ -837,22 +1136,86 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
837
1136
|
}
|
|
838
1137
|
|
|
839
1138
|
async stat(target, signal) {
|
|
1139
|
+
const sub = await this.agentSubstrate();
|
|
1140
|
+
if (sub !== undefined) {
|
|
1141
|
+
try {
|
|
1142
|
+
return await sub.stat(target, signal);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
throw this.peerError(error);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
840
1147
|
return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
|
|
841
1148
|
}
|
|
842
1149
|
|
|
1150
|
+
/**
|
|
1151
|
+
* No-follow stat one path. The agent substrate answers from the distro,
|
|
1152
|
+
* where a symlink is a first-class entry; the share backend keeps the
|
|
1153
|
+
* inherited host behaviour.
|
|
1154
|
+
* @param path - a Linux, UNC or Windows path.
|
|
1155
|
+
* @param opts - optional `cwd` override.
|
|
1156
|
+
* @param signal - optional cancellation.
|
|
1157
|
+
* @returns the path info, or undefined when absent.
|
|
1158
|
+
*/
|
|
1159
|
+
async lstat(path, opts, signal) {
|
|
1160
|
+
const sub = await this.agentSubstrate();
|
|
1161
|
+
if (sub !== undefined) {
|
|
1162
|
+
const world = await this.worldPath(path, opts?.cwd);
|
|
1163
|
+
const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
|
|
1164
|
+
if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
|
|
1165
|
+
try {
|
|
1166
|
+
return await sub.lstat(linux, signal);
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
throw this.peerError(error);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return super.lstat(path, opts, signal);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
843
1174
|
async readText(target, signal) {
|
|
1175
|
+
const sub = await this.agentSubstrate();
|
|
1176
|
+
if (sub !== undefined) {
|
|
1177
|
+
try {
|
|
1178
|
+
return await sub.readText(target, signal);
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
throw this.peerError(error);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
844
1183
|
return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
|
|
845
1184
|
}
|
|
846
1185
|
|
|
847
1186
|
async streamText(target, signal) {
|
|
1187
|
+
const sub = await this.agentSubstrate();
|
|
1188
|
+
if (sub !== undefined) {
|
|
1189
|
+
try {
|
|
1190
|
+
return await sub.streamText(target, signal);
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
throw this.peerError(error);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
848
1195
|
return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
|
|
849
1196
|
}
|
|
850
1197
|
|
|
851
1198
|
async readBytes(target, signal, maxBytes) {
|
|
1199
|
+
const sub = await this.agentSubstrate();
|
|
1200
|
+
if (sub !== undefined) {
|
|
1201
|
+
try {
|
|
1202
|
+
return await sub.readBytes(target, signal, maxBytes);
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
throw this.peerError(error);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
852
1207
|
return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
|
|
853
1208
|
}
|
|
854
1209
|
|
|
855
1210
|
async readByteRange(target, range, signal) {
|
|
1211
|
+
const sub = await this.agentSubstrate();
|
|
1212
|
+
if (sub !== undefined) {
|
|
1213
|
+
try {
|
|
1214
|
+
return await sub.readByteRange(target, range, signal);
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
throw this.peerError(error);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
856
1219
|
return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
|
|
857
1220
|
}
|
|
858
1221
|
|
|
@@ -866,6 +1229,16 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
866
1229
|
* @returns the directory rows.
|
|
867
1230
|
*/
|
|
868
1231
|
async listDir(target, signal) {
|
|
1232
|
+
const sub = await this.agentSubstrate();
|
|
1233
|
+
if (sub !== undefined) {
|
|
1234
|
+
// The substrate's rows already carry Linux display paths and canonical
|
|
1235
|
+
// identities, so no share-side rewrite follows.
|
|
1236
|
+
try {
|
|
1237
|
+
return await sub.listDir(target, signal);
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
throw this.peerError(error);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
869
1242
|
const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
|
|
870
1243
|
const distro = await this.distro();
|
|
871
1244
|
return entries.map((entry) => ({
|
|
@@ -885,13 +1258,33 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
885
1258
|
}
|
|
886
1259
|
|
|
887
1260
|
/**
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
1261
|
+
* Watch from inside the distro, where the kernel can actually report change:
|
|
1262
|
+
* a long-lived `wsl.exe` poll loop (`lib/watcher.js`) fires the seam's
|
|
1263
|
+
* invalidation callback. The seam asks for coarse invalidation, not events,
|
|
1264
|
+
* so a poll is honest — and creations and deletions are both seen, because
|
|
1265
|
+
* they bump the parent directory's mtime. Replaces the previous flat
|
|
1266
|
+
* refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
|
|
1267
|
+
*
|
|
1268
|
+
* @param target - the resolved file or directory to observe.
|
|
1269
|
+
* @param changed - the invalidation callback.
|
|
1270
|
+
* @param signal - cancels initialization; the caller closes an armed watcher.
|
|
1271
|
+
* @returns resolves once active, with the async close function.
|
|
892
1272
|
*/
|
|
893
|
-
watch() {
|
|
894
|
-
|
|
1273
|
+
async watch(target, changed, signal) {
|
|
1274
|
+
signal.throwIfAborted();
|
|
1275
|
+
const parsed = uncToPosix(target.targetKey);
|
|
1276
|
+
if (parsed === undefined) {
|
|
1277
|
+
throw new FsError(`cannot watch "${target.displayPath}": not a path inside the distro`, "FS_IO_ERROR");
|
|
1278
|
+
}
|
|
1279
|
+
const distro = await this.distro();
|
|
1280
|
+
return armDistroWatcher({
|
|
1281
|
+
wslPath: this.config.wslPath,
|
|
1282
|
+
distro,
|
|
1283
|
+
linuxPath: parsed.linuxPath,
|
|
1284
|
+
onChange: changed,
|
|
1285
|
+
onError: (error) => changed(error),
|
|
1286
|
+
signal,
|
|
1287
|
+
});
|
|
895
1288
|
}
|
|
896
1289
|
|
|
897
1290
|
/**
|
|
@@ -926,9 +1319,25 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
926
1319
|
* @returns the write outcome.
|
|
927
1320
|
*/
|
|
928
1321
|
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
1322
|
+
// The policy resolves once: the fence and the confined routing must see
|
|
1323
|
+
// the same grant, or the kernel would enforce something other than what
|
|
1324
|
+
// was checked.
|
|
1325
|
+
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
929
1326
|
// The fence runs first and its fresh target is the one checked and written,
|
|
930
1327
|
// so a refusal costs no I/O and cannot be raced by a swapped symlink.
|
|
931
|
-
const fenced = await this.checkedTarget(target,
|
|
1328
|
+
const fenced = await this.checkedTarget(target, policy);
|
|
1329
|
+
const sub = await this.agentSubstrate();
|
|
1330
|
+
if (sub !== undefined) {
|
|
1331
|
+
// The agent substrate keeps the guard: the distro-side no-replace link
|
|
1332
|
+
// is an atomic primitive, so the check-then-write window the share
|
|
1333
|
+
// backend documents closes here, exactly as on a Linux host. The policy
|
|
1334
|
+
// also selects the confined resident whose mount table matches it.
|
|
1335
|
+
try {
|
|
1336
|
+
return await sub.writeText(fenced, content, expected, signal, policy);
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
throw this.peerError(error);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
932
1341
|
await this.assertGuard(fenced, expected, signal);
|
|
933
1342
|
// The guard is deliberately not forwarded: the host backend would publish a
|
|
934
1343
|
// guarded creation with a hard link, which this filesystem rejects. Once
|
|
@@ -948,7 +1357,16 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
948
1357
|
* @returns the edit outcome.
|
|
949
1358
|
*/
|
|
950
1359
|
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
951
|
-
const
|
|
1360
|
+
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
1361
|
+
const fenced = await this.checkedTarget(target, policy);
|
|
1362
|
+
const sub = await this.agentSubstrate();
|
|
1363
|
+
if (sub !== undefined) {
|
|
1364
|
+
try {
|
|
1365
|
+
return await sub.editText(fenced, edit, expected, signal, policy);
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
throw this.peerError(error);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
952
1370
|
await this.assertGuard(fenced, expected, signal);
|
|
953
1371
|
return super.editText(fenced, edit, undefined, signal);
|
|
954
1372
|
}
|
package/lib/ports.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listening-port visibility for the distro, the Remote-WSL port-forwarding
|
|
3
|
+
* parity row's DSH-side half.
|
|
4
|
+
*
|
|
5
|
+
* There is no DSH channel that streams live facts to the model; the sanctioned
|
|
6
|
+
* one is the managed `DSH_*` namespace (`ctx.shellEnv`), whose `resolve` is
|
|
7
|
+
* SYNCHRONOUS. So the snapshot is refreshed on a timer and served stale-but-
|
|
8
|
+
* recent: `DSH_WSL_PORTS` names the ports with a listener inside the distro,
|
|
9
|
+
* at most `refreshMs` old. A dev server the model starts shows up on the next
|
|
10
|
+
* refresh, which is the granularity that matters at conversational pace.
|
|
11
|
+
*
|
|
12
|
+
* The scan reads `/proc/net/tcp` and `/proc/net/tcp6` directly — no `ss`, no
|
|
13
|
+
* `netstat`, nothing to install — and keeps sockets in state `0A` (LISTEN).
|
|
14
|
+
* Pure parser, unit-testable without a distro.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-plugin-wsl/ports
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse one `/proc/net/tcp{,6}` body into listening ports.
|
|
21
|
+
* @param {string} text - the file's content.
|
|
22
|
+
* @returns {number[]} deduplicated listening ports, ascending.
|
|
23
|
+
*/
|
|
24
|
+
export function parseListeningPorts(text) {
|
|
25
|
+
const ports = new Set();
|
|
26
|
+
for (const line of text.split("\n").slice(1)) {
|
|
27
|
+
const columns = line.trim().split(/\s+/);
|
|
28
|
+
// sl, local_address, rem_address, st, ... — st 0A is LISTEN.
|
|
29
|
+
if (columns.length < 4 || columns[3] !== "0A") continue;
|
|
30
|
+
const portHex = columns[1].split(":")[1];
|
|
31
|
+
if (portHex === undefined) continue;
|
|
32
|
+
const port = Number.parseInt(portHex, 16);
|
|
33
|
+
if (Number.isFinite(port) && port > 0) ports.add(port);
|
|
34
|
+
}
|
|
35
|
+
return [...ports].sort((a, b) => a - b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The shell script that prints both proc files' contents.
|
|
40
|
+
* @returns {string} the `sh -c` body.
|
|
41
|
+
*/
|
|
42
|
+
export function procNetScript() {
|
|
43
|
+
return "cat /proc/net/tcp /proc/net/tcp6 2>/dev/null";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot the distro's listening ports through the agent.
|
|
48
|
+
* @param {object} runner - the {@link WslAgent}-like runner.
|
|
49
|
+
* @param {number} [timeoutMs] - the exec budget.
|
|
50
|
+
* @returns {Promise<number[]>} listening ports, ascending; [] on any failure —
|
|
51
|
+
* a snapshot is a convenience, never an error the session should see.
|
|
52
|
+
*/
|
|
53
|
+
export async function listeningPorts(runner, timeoutMs = 10000) {
|
|
54
|
+
try {
|
|
55
|
+
const result = await runner.exec({ cwd: "/", argv: ["sh", "-c", procNetScript(), "ports"], timeoutMs });
|
|
56
|
+
if (result.exitCode !== 0) return [];
|
|
57
|
+
return parseListeningPorts(result.stdout.toString("utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|