dsh-plugin-wsl-env 0.5.0 → 0.7.2
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 +256 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +9 -6
- package/agent/wsl-agent.sh +41 -8
- package/cordis.patch.yml +48 -0
- package/docs/ARCHITECTURE.md +36 -22
- package/docs/CONFIGURATION.md +3 -2
- package/docs/LIMITATIONS.md +52 -54
- package/docs/PARITY.md +18 -0
- package/docs/README.zh.md +10 -7
- package/lib/fs-decisions.js +22 -25
- package/lib/fs-routing.js +176 -0
- package/lib/git-route.js +142 -0
- package/lib/index.js +92 -306
- package/lib/listing.js +47 -0
- package/lib/picker.js +121 -17
- package/lib/sandbox-core.js +157 -0
- package/lib/sandbox.js +11 -131
- package/lib/search-exec.js +114 -0
- package/lib/search-route.js +95 -0
- package/lib/subprocess.js +153 -16
- package/lib/terminal-activity.js +121 -0
- package/lib/watcher.js +63 -12
- package/lib/workspace-files-route.js +205 -0
- package/lib/workspace-files-wsl.js +341 -0
- package/lib/wsl.js +24 -41
- package/package.json +23 -3
- package/lib/fs-publish.js +0 -42
package/lib/index.js
CHANGED
|
@@ -36,20 +36,18 @@
|
|
|
36
36
|
* @module dsh-plugin-wsl
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { rename } from "node:fs/promises";
|
|
40
39
|
import { fileURLToPath } from "node:url";
|
|
41
40
|
import z from "@deepseek-ai/schemastery";
|
|
42
41
|
import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
|
|
43
42
|
import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
|
|
44
43
|
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
45
44
|
import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
|
|
46
|
-
import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc,
|
|
47
|
-
import {
|
|
45
|
+
import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
46
|
+
import { isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, shareSubstrateRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
|
|
48
47
|
import { WslSandbox } from "./sandbox.js";
|
|
49
48
|
import { armDistroWatcher } from "./watcher.js";
|
|
50
49
|
import { WslAgent } from "./agent.js";
|
|
51
50
|
import { AgentUnavailableError } from "./agent-errors.js";
|
|
52
|
-
import { publicationArgv } from "./fs-publish.js";
|
|
53
51
|
import { agentExecutionHandle } from "./agent-exec.js";
|
|
54
52
|
import { sharedAgent } from "./agent-shared.js";
|
|
55
53
|
import { AgentSubstrate } from "./fs-substrate.js";
|
|
@@ -57,7 +55,7 @@ import { FsCodedError } from "./fsio-text.js";
|
|
|
57
55
|
import { confinedAgent } from "./agent-confined.js";
|
|
58
56
|
// `runCapture` and distro discovery live in ./wsl.js so the directory picker
|
|
59
57
|
// can use them without pulling this file's executor and filesystem peers in.
|
|
60
|
-
import {
|
|
58
|
+
import { defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
|
|
61
59
|
|
|
62
60
|
/**
|
|
63
61
|
* Every model shell call gets a rebuilt managed `DSH_*` namespace (see
|
|
@@ -580,7 +578,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
580
578
|
async executeViaAgent(resolved, distro, shell) {
|
|
581
579
|
const policy = resolved.sandboxPolicy;
|
|
582
580
|
const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
|
|
583
|
-
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro })
|
|
581
|
+
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
|
|
584
582
|
: undefined;
|
|
585
583
|
const mode = policy?.mode;
|
|
586
584
|
const execution = agentExecutionHandle({
|
|
@@ -780,75 +778,48 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
780
778
|
*/
|
|
781
779
|
restrictToDistro: z.boolean().default(true),
|
|
782
780
|
/**
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
781
|
+
* How deep the in-distro watch scan walks, `1` = the watched directory and
|
|
782
|
+
* its direct children. `0` — the default — walks the whole tree, which is
|
|
783
|
+
* what the invalidation contract asks for but a real cost every poll over
|
|
784
|
+
* a tree containing a `node_modules`; a bound trades that cost for a
|
|
785
|
+
* documented blind spot: a change below the bound is not seen until
|
|
786
|
+
* another change above it fires the same callback.
|
|
789
787
|
*/
|
|
790
|
-
|
|
788
|
+
watchMaxDepth: z.natural().default(0),
|
|
791
789
|
/**
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
* are native, and the mutation guard survives to the write's no-replace
|
|
805
|
-
* publication, kernel-enforced by the confined resident when the policy is
|
|
806
|
-
* confined. `"share"` — the opt-out: the Windows-side host filesystem
|
|
807
|
-
* stack over the distro's 9p share, with the symlink-retry and publication
|
|
808
|
-
* hooks that compensate for what the share cannot do. Both substrates
|
|
809
|
-
* fence writes with the same host-side policy check; neither changes
|
|
790
|
+
* Which I/O substrate serves the file tools. Only `"agent"` exists — the
|
|
791
|
+
* resident in-distro agent (`lib/fs-substrate.js`): reads, writes and
|
|
792
|
+
* identities run where the files live, symlinks and mode bits are native,
|
|
793
|
+
* and the mutation guard survives to the write's no-replace publication,
|
|
794
|
+
* kernel-enforced by the confined resident when the policy is confined.
|
|
795
|
+
* The former `"share"` opt-out — the Windows-side host filesystem stack
|
|
796
|
+
* over the distro's 9p share — is retired: it was the one model-facing
|
|
797
|
+
* I/O path that still crossed the share, and the agent has no fallback
|
|
798
|
+
* that needs it. A profile carrying `"share"` is refused at construction
|
|
799
|
+
* with the migration (`shareSubstrateRefusal`). `watch()` rides the
|
|
800
|
+
* in-distro poll loop for the substrate regardless (`lib/watcher.js`) —
|
|
801
|
+
* it never ran through either substrate. The substrate never changes
|
|
810
802
|
* `sandboxMode`'s honesty.
|
|
811
803
|
*/
|
|
812
804
|
substrate: z.union(["share", "agent"]).default("agent"),
|
|
813
805
|
});
|
|
814
806
|
|
|
815
807
|
constructor(ctx, config) {
|
|
808
|
+
// The share substrate is refused before any peer work: a profile still
|
|
809
|
+
// carrying the retired value must fail loudly, with the migration, not
|
|
810
|
+
// silently serve the one I/O path this provider exists to close.
|
|
811
|
+
const refusal = shareSubstrateRefusal(config.substrate);
|
|
812
|
+
if (refusal !== null) throw new Error(refusal.message);
|
|
816
813
|
super(ctx, config);
|
|
817
814
|
this.resolvedDistro = config.distro;
|
|
818
815
|
/** Cached distro-user home, the base for relative input when `cwd` is empty. */
|
|
819
816
|
this.resolvedHome = undefined;
|
|
820
817
|
/** The deployment default mode — the capability fact the tool layer reads. */
|
|
821
818
|
this.defaultMode = ctx.sandboxPolicy.defaultMode;
|
|
822
|
-
/** targetKey -> canonical replacement target, for symlinked paths. */
|
|
823
|
-
this.canonicalTargets = new Map();
|
|
824
819
|
/** The lazy agent-backed substrate, built on first use (see `agentSubstrate`). */
|
|
825
820
|
this.agentSubstrateInstance = undefined;
|
|
826
821
|
/** The distro-side bwrap usability probe, shared with the confined agents. */
|
|
827
822
|
this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
|
|
828
|
-
// `dsh-fs-local` takes a Windows branch whenever the destination already
|
|
829
|
-
// exists — which is how it knows to preserve the replaced file's security
|
|
830
|
-
// descriptor, and what makes every write to an existing file fail here:
|
|
831
|
-
//
|
|
832
|
-
// * it reads that descriptor with `GetFileSecurityW` and applies it to the
|
|
833
|
-
// staged temp. A 9p share carries no Windows security descriptor, so the
|
|
834
|
-
// call fails with `EIO (Win32 1)` and takes the whole write with it —
|
|
835
|
-
// `edit` and every overwrite, not just an exotic path.
|
|
836
|
-
// * it publishes with `ReplaceFileW`, which is not a 9p primitive.
|
|
837
|
-
//
|
|
838
|
-
// Both are replaced with their POSIX equivalents: the staged temp gets the
|
|
839
|
-
// target's real mode from inside the distro (see `copyModeInDistro` — the
|
|
840
|
-
// share ignores a host-side chmod), and publication is the plain atomic
|
|
841
|
-
// rename the share does support.
|
|
842
|
-
//
|
|
843
|
-
// Trade-off worth knowing: those two hooks are the only DACL handling in the
|
|
844
|
-
// inherited backend, so a *Windows* path served by this provider loses its
|
|
845
|
-
// descriptor preservation. This provider exists to serve distro paths, where
|
|
846
|
-
// no DACL can exist in the first place.
|
|
847
|
-
this.internals = {
|
|
848
|
-
...this.internals,
|
|
849
|
-
copyFileDacl: async () => {},
|
|
850
|
-
replaceFile: (replaced, replacement) => this.publish(replaced, replacement),
|
|
851
|
-
};
|
|
852
823
|
}
|
|
853
824
|
|
|
854
825
|
/**
|
|
@@ -862,50 +833,6 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
862
833
|
return this.config.sandbox ? this.defaultMode : undefined;
|
|
863
834
|
}
|
|
864
835
|
|
|
865
|
-
/**
|
|
866
|
-
* Publish a staged temp file over its target the way a 9p share supports:
|
|
867
|
-
* give the temp the target's POSIX mode inside the distro, then rename it into
|
|
868
|
-
* place.
|
|
869
|
-
*
|
|
870
|
-
* The mode is applied before the rename on purpose. The share drops a mode set
|
|
871
|
-
* from the Windows side, but a rename preserves the inode's mode — so setting
|
|
872
|
-
* it on the staged temp is what makes the published file keep the bits it had,
|
|
873
|
-
* without a second in-distro call after publication and without the version
|
|
874
|
-
* the write returns going stale behind a late `chmod`.
|
|
875
|
-
*
|
|
876
|
-
* @param replaced - the existing file being replaced (a UNC path).
|
|
877
|
-
* @param replacement - the staged temp file holding the new content.
|
|
878
|
-
*/
|
|
879
|
-
async publish(replaced, replacement) {
|
|
880
|
-
const source = uncToPosix(replaced);
|
|
881
|
-
const staged = uncToPosix(replacement);
|
|
882
|
-
if (source !== undefined && staged !== undefined) {
|
|
883
|
-
if (this.config.agent) {
|
|
884
|
-
try {
|
|
885
|
-
// One in-distro round trip: mode copy + atomic rename. The staged
|
|
886
|
-
// temp is on the share, but the agent reaches it by its Linux path,
|
|
887
|
-
// where the kernel does the rename — no 9p publication involved.
|
|
888
|
-
const agent = await this.publicationAgent();
|
|
889
|
-
const result = await agent.exec({
|
|
890
|
-
cwd: "/",
|
|
891
|
-
argv: publicationArgv(source.linuxPath, staged.linuxPath),
|
|
892
|
-
timeoutMs: 30000,
|
|
893
|
-
});
|
|
894
|
-
if (result.exitCode === 0) return;
|
|
895
|
-
// A nonzero from the script is a real publication failure (chmod or
|
|
896
|
-
// rename); report it rather than retrying through the legacy path,
|
|
897
|
-
// which would fail the same way but slower.
|
|
898
|
-
throw new FsError(`publishing "${replaced}" failed inside the distro (exit ${result.exitCode})`, "FS_IO_ERROR");
|
|
899
|
-
} catch (error) {
|
|
900
|
-
// The agent being out is the ONLY thing the legacy path exists for.
|
|
901
|
-
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
|
|
905
|
-
}
|
|
906
|
-
await rename(replacement, replaced);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
836
|
/**
|
|
910
837
|
* The resident agent serving this filesystem's in-distro side work, created
|
|
911
838
|
* on first use and kept for the provider's lifetime (its own idle timer
|
|
@@ -920,13 +847,11 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
920
847
|
}
|
|
921
848
|
|
|
922
849
|
/**
|
|
923
|
-
* The agent-backed substrate
|
|
924
|
-
*
|
|
925
|
-
* @returns {Promise<AgentSubstrate
|
|
926
|
-
* when the share backend is configured.
|
|
850
|
+
* The agent-backed substrate, built on first use over the same resident
|
|
851
|
+
* agent every other distro side work shares.
|
|
852
|
+
* @returns {Promise<AgentSubstrate>} the substrate.
|
|
927
853
|
*/
|
|
928
854
|
async agentSubstrate() {
|
|
929
|
-
if (this.config.substrate !== "agent") return undefined;
|
|
930
855
|
if (this.agentSubstrateInstance === undefined) {
|
|
931
856
|
this.agentSubstrateInstance = new AgentSubstrate({
|
|
932
857
|
agent: await this.publicationAgent(),
|
|
@@ -1035,19 +960,13 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1035
960
|
*/
|
|
1036
961
|
async resolve(path, opts) {
|
|
1037
962
|
const world = await this.worldPath(path, opts?.cwd);
|
|
1038
|
-
const
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
} catch (error) {
|
|
1045
|
-
throw this.peerError(error);
|
|
1046
|
-
}
|
|
963
|
+
const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
|
|
964
|
+
if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
|
|
965
|
+
try {
|
|
966
|
+
return await (await this.agentSubstrate()).resolve(linux, opts?.signal);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
throw this.peerError(error);
|
|
1047
969
|
}
|
|
1048
|
-
const distro = await this.distro();
|
|
1049
|
-
const target = await super.resolve(world, opts);
|
|
1050
|
-
return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
|
|
1051
970
|
}
|
|
1052
971
|
|
|
1053
972
|
/**
|
|
@@ -1084,185 +1003,78 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1084
1003
|
throw new FsError(denial.message, denial.code);
|
|
1085
1004
|
}
|
|
1086
1005
|
|
|
1087
|
-
|
|
1088
|
-
* The canonical replacement target for a Linux symlink this share cannot
|
|
1089
|
-
* traverse, or undefined when the path is not one.
|
|
1090
|
-
* @param target - the resolved target that failed a read-side operation.
|
|
1091
|
-
* @param signal - optional cancellation.
|
|
1092
|
-
* @returns the replacement target, or undefined.
|
|
1093
|
-
*/
|
|
1094
|
-
async canonicalTarget(target, signal) {
|
|
1095
|
-
if (!this.config.resolveSymlinks) return undefined;
|
|
1096
|
-
const cached = this.canonicalTargets.get(target.targetKey);
|
|
1097
|
-
if (cached !== undefined) return cached;
|
|
1098
|
-
const parsed = uncToPosix(target.targetKey);
|
|
1099
|
-
if (parsed === undefined) return undefined;
|
|
1100
|
-
// The agent serves the canonical path when it is up (no extra `wsl.exe`
|
|
1101
|
-
// per distinct failing path); the one-shot helper is the fallback and the
|
|
1102
|
-
// `agent: false` path.
|
|
1103
|
-
let canonical;
|
|
1104
|
-
if (this.config.agent) {
|
|
1105
|
-
try {
|
|
1106
|
-
const agent = await this.publicationAgent();
|
|
1107
|
-
const result = await agent.exec({ cwd: "/", argv: ["readlink", "-f", parsed.linuxPath], timeoutMs: 10000, signal });
|
|
1108
|
-
const resolved = result.stdout.toString("utf8").trim();
|
|
1109
|
-
canonical = resolved.startsWith("/") ? resolved : undefined;
|
|
1110
|
-
} catch (error) {
|
|
1111
|
-
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
if (canonical === undefined) {
|
|
1115
|
-
canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
|
|
1116
|
-
}
|
|
1117
|
-
if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
|
|
1118
|
-
const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
|
|
1119
|
-
this.canonicalTargets.set(target.targetKey, replacement);
|
|
1120
|
-
return replacement;
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
/**
|
|
1124
|
-
* Run one read-side operation, retrying once through the distro's canonical
|
|
1125
|
-
* path when the share cannot follow a symlink. `displayPath` is preserved, so
|
|
1126
|
-
* the model still sees the path it asked for while `targetKey` names the file
|
|
1127
|
-
* the host can actually open.
|
|
1128
|
-
*
|
|
1129
|
-
* @param target - the resolved target.
|
|
1130
|
-
* @param signal - optional cancellation.
|
|
1131
|
-
* @param operation - the inherited operation to attempt.
|
|
1132
|
-
* @returns the operation's result.
|
|
1133
|
-
*/
|
|
1134
|
-
async withCanonicalRetry(target, signal, operation) {
|
|
1135
|
-
let value;
|
|
1006
|
+
async stat(target, signal) {
|
|
1136
1007
|
try {
|
|
1137
|
-
|
|
1008
|
+
return await (await this.agentSubstrate()).stat(target, signal);
|
|
1138
1009
|
} catch (error) {
|
|
1139
|
-
|
|
1140
|
-
const canonical = await this.canonicalTarget(target, signal);
|
|
1141
|
-
if (canonical === undefined) throw error;
|
|
1142
|
-
return operation(canonical);
|
|
1143
|
-
}
|
|
1144
|
-
// `stat` reports an absent file by RETURNING undefined rather than throwing,
|
|
1145
|
-
// and an untraversable symlink takes exactly that path over this share. The
|
|
1146
|
-
// model-facing read tool branches on precisely that undefined — it resolves,
|
|
1147
|
-
// stats, and renders "not found" itself — so retrying only the throw is not
|
|
1148
|
-
// enough: the silent miss has to be retried here too.
|
|
1149
|
-
if (value === undefined) {
|
|
1150
|
-
const canonical = await this.canonicalTarget(target, signal);
|
|
1151
|
-
if (canonical !== undefined) return operation(canonical);
|
|
1010
|
+
throw this.peerError(error);
|
|
1152
1011
|
}
|
|
1153
|
-
return value;
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
async stat(target, signal) {
|
|
1157
|
-
const sub = await this.agentSubstrate();
|
|
1158
|
-
if (sub !== undefined) {
|
|
1159
|
-
try {
|
|
1160
|
-
return await sub.stat(target, signal);
|
|
1161
|
-
} catch (error) {
|
|
1162
|
-
throw this.peerError(error);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
|
|
1166
1012
|
}
|
|
1167
1013
|
|
|
1168
1014
|
/**
|
|
1169
1015
|
* No-follow stat one path. The agent substrate answers from the distro,
|
|
1170
|
-
* where a symlink is a first-class entry
|
|
1171
|
-
* inherited host behaviour.
|
|
1016
|
+
* where a symlink is a first-class entry.
|
|
1172
1017
|
* @param path - a Linux, UNC or Windows path.
|
|
1173
1018
|
* @param opts - optional `cwd` override.
|
|
1174
1019
|
* @param signal - optional cancellation.
|
|
1175
1020
|
* @returns the path info, or undefined when absent.
|
|
1176
1021
|
*/
|
|
1177
1022
|
async lstat(path, opts, signal) {
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
} catch (error) {
|
|
1186
|
-
throw this.peerError(error);
|
|
1187
|
-
}
|
|
1023
|
+
const world = await this.worldPath(path, opts?.cwd);
|
|
1024
|
+
const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
|
|
1025
|
+
if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
|
|
1026
|
+
try {
|
|
1027
|
+
return await (await this.agentSubstrate()).lstat(linux, signal);
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
throw this.peerError(error);
|
|
1188
1030
|
}
|
|
1189
|
-
return super.lstat(path, opts, signal);
|
|
1190
1031
|
}
|
|
1191
1032
|
|
|
1192
1033
|
async readText(target, signal) {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
} catch (error) {
|
|
1198
|
-
throw this.peerError(error);
|
|
1199
|
-
}
|
|
1034
|
+
try {
|
|
1035
|
+
return await (await this.agentSubstrate()).readText(target, signal);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
throw this.peerError(error);
|
|
1200
1038
|
}
|
|
1201
|
-
return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
|
|
1202
1039
|
}
|
|
1203
1040
|
|
|
1204
1041
|
async streamText(target, signal) {
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
} catch (error) {
|
|
1210
|
-
throw this.peerError(error);
|
|
1211
|
-
}
|
|
1042
|
+
try {
|
|
1043
|
+
return await (await this.agentSubstrate()).streamText(target, signal);
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
throw this.peerError(error);
|
|
1212
1046
|
}
|
|
1213
|
-
return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
|
|
1214
1047
|
}
|
|
1215
1048
|
|
|
1216
1049
|
async readBytes(target, signal, maxBytes) {
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
} catch (error) {
|
|
1222
|
-
throw this.peerError(error);
|
|
1223
|
-
}
|
|
1050
|
+
try {
|
|
1051
|
+
return await (await this.agentSubstrate()).readBytes(target, signal, maxBytes);
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
throw this.peerError(error);
|
|
1224
1054
|
}
|
|
1225
|
-
return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
|
|
1226
1055
|
}
|
|
1227
1056
|
|
|
1228
1057
|
async readByteRange(target, range, signal) {
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
} catch (error) {
|
|
1234
|
-
throw this.peerError(error);
|
|
1235
|
-
}
|
|
1058
|
+
try {
|
|
1059
|
+
return await (await this.agentSubstrate()).readByteRange(target, range, signal);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
throw this.peerError(error);
|
|
1236
1062
|
}
|
|
1237
|
-
return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
|
|
1238
1063
|
}
|
|
1239
1064
|
|
|
1240
1065
|
/**
|
|
1241
|
-
* List one level
|
|
1242
|
-
*
|
|
1243
|
-
* which would leak `\\wsl.localhost\...` into model-facing output.
|
|
1244
|
-
*
|
|
1066
|
+
* List one directory level. The substrate's rows already carry Linux display
|
|
1067
|
+
* paths and canonical identities — nothing to rewrite.
|
|
1245
1068
|
* @param target - the resolved directory.
|
|
1246
1069
|
* @param signal - optional cancellation.
|
|
1247
1070
|
* @returns the directory rows.
|
|
1248
1071
|
*/
|
|
1249
1072
|
async listDir(target, signal) {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
try {
|
|
1255
|
-
return await sub.listDir(target, signal);
|
|
1256
|
-
} catch (error) {
|
|
1257
|
-
throw this.peerError(error);
|
|
1258
|
-
}
|
|
1073
|
+
try {
|
|
1074
|
+
return await (await this.agentSubstrate()).listDir(target, signal);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
throw this.peerError(error);
|
|
1259
1077
|
}
|
|
1260
|
-
const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
|
|
1261
|
-
const distro = await this.distro();
|
|
1262
|
-
return entries.map((entry) => ({
|
|
1263
|
-
...entry,
|
|
1264
|
-
target: { targetKey: entry.target.targetKey, displayPath: toDisplayPath(entry.target.targetKey, distro) },
|
|
1265
|
-
}));
|
|
1266
1078
|
}
|
|
1267
1079
|
|
|
1268
1080
|
/**
|
|
@@ -1283,6 +1095,10 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1283
1095
|
* they bump the parent directory's mtime. Replaces the previous flat
|
|
1284
1096
|
* refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
|
|
1285
1097
|
*
|
|
1098
|
+
* A target that disappears mid-watch ends the loop with a report through
|
|
1099
|
+
* `changed` — never the armed-but-never-fires silence a missing directory
|
|
1100
|
+
* would otherwise produce. The scan's depth is `watchMaxDepth`.
|
|
1101
|
+
*
|
|
1286
1102
|
* @param target - the resolved file or directory to observe.
|
|
1287
1103
|
* @param changed - the invalidation callback.
|
|
1288
1104
|
* @param signal - cancels initialization; the caller closes an armed watcher.
|
|
@@ -1302,26 +1118,17 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1302
1118
|
onChange: changed,
|
|
1303
1119
|
onError: (error) => changed(error),
|
|
1304
1120
|
signal,
|
|
1121
|
+
maxDepth: this.config.watchMaxDepth,
|
|
1305
1122
|
});
|
|
1306
1123
|
}
|
|
1307
1124
|
|
|
1308
1125
|
/**
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
|
|
1315
|
-
async assertGuard(target, expected, signal) {
|
|
1316
|
-
if (expected === undefined) return;
|
|
1317
|
-
const existing = await this.stat(target, signal);
|
|
1318
|
-
const refusal = guardRefusal(expected, existing, target.displayPath);
|
|
1319
|
-
if (refusal !== null) throw new FsError(refusal.message, refusal.code);
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
/**
|
|
1323
|
-
* Fence the write by the per-call policy, then keep the guard's observable
|
|
1324
|
-
* behaviour.
|
|
1126
|
+
* Fence the write by the per-call policy, then publish through the
|
|
1127
|
+
* substrate. The fence's fresh target is the one checked and written, so a
|
|
1128
|
+
* refusal costs no I/O and cannot be raced by a swapped symlink; the guard
|
|
1129
|
+
* rides the write op, where the agent re-verifies it one syscall before the
|
|
1130
|
+
* rename and the confined resident enforces it in the kernel.
|
|
1131
|
+
*
|
|
1325
1132
|
* @param target - the resolved target.
|
|
1326
1133
|
* @param content - the full new file content.
|
|
1327
1134
|
* @param expected - the write intent.
|
|
@@ -1331,35 +1138,19 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1331
1138
|
* @returns the write outcome.
|
|
1332
1139
|
*/
|
|
1333
1140
|
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
1334
|
-
// The policy resolves once: the fence and the confined routing must see
|
|
1335
|
-
// the same grant, or the kernel would enforce something other than what
|
|
1336
|
-
// was checked.
|
|
1337
1141
|
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
1338
|
-
// The fence runs first and its fresh target is the one checked and written,
|
|
1339
|
-
// so a refusal costs no I/O and cannot be raced by a swapped symlink.
|
|
1340
1142
|
const fenced = await this.checkedTarget(target, policy);
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
// backend documents closes here, exactly as on a Linux host. The policy
|
|
1346
|
-
// also selects the confined resident whose mount table matches it.
|
|
1347
|
-
try {
|
|
1348
|
-
return await sub.writeText(fenced, content, expected, signal, policy);
|
|
1349
|
-
} catch (error) {
|
|
1350
|
-
throw this.peerError(error);
|
|
1351
|
-
}
|
|
1143
|
+
try {
|
|
1144
|
+
return await (await this.agentSubstrate()).writeText(fenced, content, expected, signal, policy);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
throw this.peerError(error);
|
|
1352
1147
|
}
|
|
1353
|
-
await this.assertGuard(fenced, expected, signal);
|
|
1354
|
-
// The guard is deliberately not forwarded: the host backend would publish a
|
|
1355
|
-
// guarded creation with a hard link, which this filesystem rejects. Once
|
|
1356
|
-
// past the guard, publication is the overridden `publish` (see the
|
|
1357
|
-
// constructor), not the inherited Windows one.
|
|
1358
|
-
return super.writeText(fenced, content, undefined, signal);
|
|
1359
1148
|
}
|
|
1360
1149
|
|
|
1361
1150
|
/**
|
|
1362
|
-
* Fence the edit by the same policy, keeping the guard's observable
|
|
1151
|
+
* Fence the edit by the same policy, keeping the guard's observable
|
|
1152
|
+
* behaviour.
|
|
1153
|
+
*
|
|
1363
1154
|
* @param target - the resolved target.
|
|
1364
1155
|
* @param edit - the literal search/replace request.
|
|
1365
1156
|
* @param expected - the version guard.
|
|
@@ -1371,15 +1162,10 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1371
1162
|
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
1372
1163
|
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
1373
1164
|
const fenced = await this.checkedTarget(target, policy);
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
} catch (error) {
|
|
1379
|
-
throw this.peerError(error);
|
|
1380
|
-
}
|
|
1165
|
+
try {
|
|
1166
|
+
return await (await this.agentSubstrate()).editText(fenced, edit, expected, signal, policy);
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
throw this.peerError(error);
|
|
1381
1169
|
}
|
|
1382
|
-
await this.assertGuard(fenced, expected, signal);
|
|
1383
|
-
return super.editText(fenced, edit, undefined, signal);
|
|
1384
1170
|
}
|
|
1385
1171
|
}
|
package/lib/listing.js
CHANGED
|
@@ -110,3 +110,50 @@ export function boundedInsert(window, candidate, keep) {
|
|
|
110
110
|
window.pop();
|
|
111
111
|
return true;
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The distro-side argv one picker level is listed with.
|
|
116
|
+
*
|
|
117
|
+
* `-1` is one entry per line, `-A` keeps dot-entries but drops `.` and `..`,
|
|
118
|
+
* `-L` follows symlinks so a link to a directory reports as a directory and a
|
|
119
|
+
* broken link drops out (exactly what the per-entry `stat` the host path used
|
|
120
|
+
* to pay one 9p round trip for), and `-p` marks directories with the trailing
|
|
121
|
+
* `/` the parser keys on.
|
|
122
|
+
*
|
|
123
|
+
* @param linuxDir - the absolute Linux directory to list.
|
|
124
|
+
* @returns the argv for the agent or a `wsl.exe --exec` launch.
|
|
125
|
+
*/
|
|
126
|
+
export function lsListingArgv(linuxDir) {
|
|
127
|
+
return ["ls", "-1ALp", "--", linuxDir];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse one `ls -1ALp` listing into the picker's rows.
|
|
132
|
+
*
|
|
133
|
+
* A line is a row when it ends with `/` — a directory, or a symlink `-L`
|
|
134
|
+
* resolved to one; files and dangling links are skipped, matching the host
|
|
135
|
+
* path's behaviour. The parse reads the listing as UTF-8 lines, so a filename
|
|
136
|
+
* containing a newline would split into two rows — the same limitation class
|
|
137
|
+
* the one-shot transport has always had, accepted here with the 9p walk it
|
|
138
|
+
* replaces.
|
|
139
|
+
*
|
|
140
|
+
* @param stdout - the listing's raw stdout.
|
|
141
|
+
* @param parent - the listed directory, in the UNC form the rows carry.
|
|
142
|
+
* @param maxEntries - the row bound; a level with more is flagged truncated.
|
|
143
|
+
* @returns the first `maxEntries` rows and whether the level had more.
|
|
144
|
+
*/
|
|
145
|
+
export function parseLsListing(stdout, parent, maxEntries) {
|
|
146
|
+
const rows = [];
|
|
147
|
+
let truncated = false;
|
|
148
|
+
for (const line of String(stdout).split("\n")) {
|
|
149
|
+
if (!line.endsWith("/")) continue;
|
|
150
|
+
const name = line.slice(0, -1);
|
|
151
|
+
if (name.length === 0) continue;
|
|
152
|
+
if (rows.length === maxEntries) {
|
|
153
|
+
truncated = true;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
rows.push({ name, path: win32.join(parent, name), hidden: name.startsWith(".") });
|
|
157
|
+
}
|
|
158
|
+
return { rows, truncated };
|
|
159
|
+
}
|