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/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, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
47
- import { guardRefusal, isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
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 { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
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
- * When a read-side operation reports `FS_NOT_FOUND` on a Linux path, ask the
784
- * distro for that path's canonical form and retry once. This is what makes
785
- * symlinked paths usable at all: the UNC share cannot traverse Linux
786
- * symlinks, so `/etc/os-release`, `/bin` and `/lib` would otherwise be
787
- * unreachable from the model's read tools. Costs one `wsl.exe` call per
788
- * distinct failing path, cached.
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
- resolveSymlinks: z.boolean().default(true),
788
+ watchMaxDepth: z.natural().default(0),
791
789
  /**
792
- * Run the in-distro side of publication (mode copy + atomic rename) through
793
- * the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
794
- * `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
795
- * legacy two-step path for that call and, after the agent's own rebuild
796
- * budget is spent, permanently — this flag never changes what is published,
797
- * only how many round trips it costs.
798
- */
799
- agent: z.boolean().default(true),
800
- /**
801
- * Which I/O substrate serves the file tools. `"agent"` (the default) —
802
- * the resident in-distro agent (`lib/fs-substrate.js`): reads, writes,
803
- * identities and watches run where the files live, symlinks and mode bits
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 for `substrate: "agent"`, built on first use
924
- * over the same resident agent the publication path shares.
925
- * @returns {Promise<AgentSubstrate|undefined>} the substrate, or undefined
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 sub = await this.agentSubstrate();
1039
- if (sub !== undefined) {
1040
- const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1041
- if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
1042
- try {
1043
- return await sub.resolve(linux, opts?.signal);
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
- value = await operation(target);
1008
+ return await (await this.agentSubstrate()).stat(target, signal);
1138
1009
  } catch (error) {
1139
- if (!(error instanceof FsError) || error.code !== "FS_NOT_FOUND") throw error;
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; the share backend keeps the
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 sub = await this.agentSubstrate();
1179
- if (sub !== undefined) {
1180
- const world = await this.worldPath(path, opts?.cwd);
1181
- const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1182
- if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1183
- try {
1184
- return await sub.lstat(linux, signal);
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
- const sub = await this.agentSubstrate();
1194
- if (sub !== undefined) {
1195
- try {
1196
- return await sub.readText(target, signal);
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
- const sub = await this.agentSubstrate();
1206
- if (sub !== undefined) {
1207
- try {
1208
- return await sub.streamText(target, signal);
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
- const sub = await this.agentSubstrate();
1218
- if (sub !== undefined) {
1219
- try {
1220
- return await sub.readBytes(target, signal, maxBytes);
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
- const sub = await this.agentSubstrate();
1230
- if (sub !== undefined) {
1231
- try {
1232
- return await sub.readByteRange(target, range, signal);
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, rewriting each child's display path into the distro's
1242
- * coordinate system — the host backend reports children with UNC display paths,
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
- const sub = await this.agentSubstrate();
1251
- if (sub !== undefined) {
1252
- // The substrate's rows already carry Linux display paths and canonical
1253
- // identities, so no share-side rewrite follows.
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
- * Check the caller's mutation guard immediately before publication.
1310
- * @param target - the resolved target.
1311
- * @param expected - the write intent, or undefined for an unconditional write.
1312
- * @param signal - optional cancellation.
1313
- * @throws FsError with the same codes the host backend raises.
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
- const sub = await this.agentSubstrate();
1342
- if (sub !== undefined) {
1343
- // The agent substrate keeps the guard: the distro-side no-replace link
1344
- // is an atomic primitive, so the check-then-write window the share
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 behaviour.
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
- const sub = await this.agentSubstrate();
1375
- if (sub !== undefined) {
1376
- try {
1377
- return await sub.editText(fenced, edit, expected, signal, policy);
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
+ }