dsh-plugin-wsl-env 0.6.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
@@ -779,15 +777,6 @@ export class WslFileSystem extends LocalFileSystem {
779
777
  * workspace the operator selected.
780
778
  */
781
779
  restrictToDistro: z.boolean().default(true),
782
- /**
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.
789
- */
790
- resolveSymlinks: z.boolean().default(true),
791
780
  /**
792
781
  * How deep the in-distro watch scan walks, `1` = the watched directory and
793
782
  * its direct children. `0` — the default — walks the whole tree, which is
@@ -798,68 +787,39 @@ export class WslFileSystem extends LocalFileSystem {
798
787
  */
799
788
  watchMaxDepth: z.natural().default(0),
800
789
  /**
801
- * Run the in-distro side of publication (mode copy + atomic rename) through
802
- * the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
803
- * `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
804
- * legacy two-step path for that call and, after the agent's own rebuild
805
- * budget is spent, permanently — this flag never changes what is published,
806
- * only how many round trips it costs.
807
- */
808
- agent: z.boolean().default(true),
809
- /**
810
- * Which I/O substrate serves the file tools. `"agent"` (the default) —
811
- * the resident in-distro agent (`lib/fs-substrate.js`): reads, writes and
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
812
792
  * identities run where the files live, symlinks and mode bits are native,
813
793
  * and the mutation guard survives to the write's no-replace publication,
814
794
  * kernel-enforced by the confined resident when the policy is confined.
815
- * `"share"` — the opt-out: the Windows-side host filesystem stack over
816
- * the distro's 9p share, with the symlink-retry and publication hooks
817
- * that compensate for what the share cannot do. Both substrates fence
818
- * writes with the same host-side policy check, and `watch()` rides the
819
- * same in-distro poll loop for both (`lib/watcher.js`) — it never runs
820
- * through either substrate. Neither substrate changes `sandboxMode`'s
821
- * honesty.
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
802
+ * `sandboxMode`'s honesty.
822
803
  */
823
804
  substrate: z.union(["share", "agent"]).default("agent"),
824
805
  });
825
806
 
826
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);
827
813
  super(ctx, config);
828
814
  this.resolvedDistro = config.distro;
829
815
  /** Cached distro-user home, the base for relative input when `cwd` is empty. */
830
816
  this.resolvedHome = undefined;
831
817
  /** The deployment default mode — the capability fact the tool layer reads. */
832
818
  this.defaultMode = ctx.sandboxPolicy.defaultMode;
833
- /** targetKey -> canonical replacement target, for symlinked paths. */
834
- this.canonicalTargets = new Map();
835
819
  /** The lazy agent-backed substrate, built on first use (see `agentSubstrate`). */
836
820
  this.agentSubstrateInstance = undefined;
837
821
  /** The distro-side bwrap usability probe, shared with the confined agents. */
838
822
  this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
839
- // `dsh-fs-local` takes a Windows branch whenever the destination already
840
- // exists — which is how it knows to preserve the replaced file's security
841
- // descriptor, and what makes every write to an existing file fail here:
842
- //
843
- // * it reads that descriptor with `GetFileSecurityW` and applies it to the
844
- // staged temp. A 9p share carries no Windows security descriptor, so the
845
- // call fails with `EIO (Win32 1)` and takes the whole write with it —
846
- // `edit` and every overwrite, not just an exotic path.
847
- // * it publishes with `ReplaceFileW`, which is not a 9p primitive.
848
- //
849
- // Both are replaced with their POSIX equivalents: the staged temp gets the
850
- // target's real mode from inside the distro (see `copyModeInDistro` — the
851
- // share ignores a host-side chmod), and publication is the plain atomic
852
- // rename the share does support.
853
- //
854
- // Trade-off worth knowing: those two hooks are the only DACL handling in the
855
- // inherited backend, so a *Windows* path served by this provider loses its
856
- // descriptor preservation. This provider exists to serve distro paths, where
857
- // no DACL can exist in the first place.
858
- this.internals = {
859
- ...this.internals,
860
- copyFileDacl: async () => {},
861
- replaceFile: (replaced, replacement) => this.publish(replaced, replacement),
862
- };
863
823
  }
864
824
 
865
825
  /**
@@ -873,50 +833,6 @@ export class WslFileSystem extends LocalFileSystem {
873
833
  return this.config.sandbox ? this.defaultMode : undefined;
874
834
  }
875
835
 
876
- /**
877
- * Publish a staged temp file over its target the way a 9p share supports:
878
- * give the temp the target's POSIX mode inside the distro, then rename it into
879
- * place.
880
- *
881
- * The mode is applied before the rename on purpose. The share drops a mode set
882
- * from the Windows side, but a rename preserves the inode's mode — so setting
883
- * it on the staged temp is what makes the published file keep the bits it had,
884
- * without a second in-distro call after publication and without the version
885
- * the write returns going stale behind a late `chmod`.
886
- *
887
- * @param replaced - the existing file being replaced (a UNC path).
888
- * @param replacement - the staged temp file holding the new content.
889
- */
890
- async publish(replaced, replacement) {
891
- const source = uncToPosix(replaced);
892
- const staged = uncToPosix(replacement);
893
- if (source !== undefined && staged !== undefined) {
894
- if (this.config.agent) {
895
- try {
896
- // One in-distro round trip: mode copy + atomic rename. The staged
897
- // temp is on the share, but the agent reaches it by its Linux path,
898
- // where the kernel does the rename — no 9p publication involved.
899
- const agent = await this.publicationAgent();
900
- const result = await agent.exec({
901
- cwd: "/",
902
- argv: publicationArgv(source.linuxPath, staged.linuxPath),
903
- timeoutMs: 30000,
904
- });
905
- if (result.exitCode === 0) return;
906
- // A nonzero from the script is a real publication failure (chmod or
907
- // rename); report it rather than retrying through the legacy path,
908
- // which would fail the same way but slower.
909
- throw new FsError(`publishing "${replaced}" failed inside the distro (exit ${result.exitCode})`, "FS_IO_ERROR");
910
- } catch (error) {
911
- // The agent being out is the ONLY thing the legacy path exists for.
912
- if (!(error instanceof AgentUnavailableError)) throw error;
913
- }
914
- }
915
- await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
916
- }
917
- await rename(replacement, replaced);
918
- }
919
-
920
836
  /**
921
837
  * The resident agent serving this filesystem's in-distro side work, created
922
838
  * on first use and kept for the provider's lifetime (its own idle timer
@@ -931,13 +847,11 @@ export class WslFileSystem extends LocalFileSystem {
931
847
  }
932
848
 
933
849
  /**
934
- * The agent-backed substrate for `substrate: "agent"`, built on first use
935
- * over the same resident agent the publication path shares.
936
- * @returns {Promise<AgentSubstrate|undefined>} the substrate, or undefined
937
- * 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.
938
853
  */
939
854
  async agentSubstrate() {
940
- if (this.config.substrate !== "agent") return undefined;
941
855
  if (this.agentSubstrateInstance === undefined) {
942
856
  this.agentSubstrateInstance = new AgentSubstrate({
943
857
  agent: await this.publicationAgent(),
@@ -1046,19 +960,13 @@ export class WslFileSystem extends LocalFileSystem {
1046
960
  */
1047
961
  async resolve(path, opts) {
1048
962
  const world = await this.worldPath(path, opts?.cwd);
1049
- const sub = await this.agentSubstrate();
1050
- if (sub !== undefined) {
1051
- const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1052
- if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
1053
- try {
1054
- return await sub.resolve(linux, opts?.signal);
1055
- } catch (error) {
1056
- throw this.peerError(error);
1057
- }
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);
1058
969
  }
1059
- const distro = await this.distro();
1060
- const target = await super.resolve(world, opts);
1061
- return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
1062
970
  }
1063
971
 
1064
972
  /**
@@ -1095,185 +1003,78 @@ export class WslFileSystem extends LocalFileSystem {
1095
1003
  throw new FsError(denial.message, denial.code);
1096
1004
  }
1097
1005
 
1098
- /**
1099
- * The canonical replacement target for a Linux symlink this share cannot
1100
- * traverse, or undefined when the path is not one.
1101
- * @param target - the resolved target that failed a read-side operation.
1102
- * @param signal - optional cancellation.
1103
- * @returns the replacement target, or undefined.
1104
- */
1105
- async canonicalTarget(target, signal) {
1106
- if (!this.config.resolveSymlinks) return undefined;
1107
- const cached = this.canonicalTargets.get(target.targetKey);
1108
- if (cached !== undefined) return cached;
1109
- const parsed = uncToPosix(target.targetKey);
1110
- if (parsed === undefined) return undefined;
1111
- // The agent serves the canonical path when it is up (no extra `wsl.exe`
1112
- // per distinct failing path); the one-shot helper is the fallback and the
1113
- // `agent: false` path.
1114
- let canonical;
1115
- if (this.config.agent) {
1116
- try {
1117
- const agent = await this.publicationAgent();
1118
- const result = await agent.exec({ cwd: "/", argv: ["readlink", "-f", parsed.linuxPath], timeoutMs: 10000, signal });
1119
- const resolved = result.stdout.toString("utf8").trim();
1120
- canonical = resolved.startsWith("/") ? resolved : undefined;
1121
- } catch (error) {
1122
- if (!(error instanceof AgentUnavailableError)) throw error;
1123
- }
1124
- }
1125
- if (canonical === undefined) {
1126
- canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
1127
- }
1128
- if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
1129
- const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
1130
- this.canonicalTargets.set(target.targetKey, replacement);
1131
- return replacement;
1132
- }
1133
-
1134
- /**
1135
- * Run one read-side operation, retrying once through the distro's canonical
1136
- * path when the share cannot follow a symlink. `displayPath` is preserved, so
1137
- * the model still sees the path it asked for while `targetKey` names the file
1138
- * the host can actually open.
1139
- *
1140
- * @param target - the resolved target.
1141
- * @param signal - optional cancellation.
1142
- * @param operation - the inherited operation to attempt.
1143
- * @returns the operation's result.
1144
- */
1145
- async withCanonicalRetry(target, signal, operation) {
1146
- let value;
1006
+ async stat(target, signal) {
1147
1007
  try {
1148
- value = await operation(target);
1008
+ return await (await this.agentSubstrate()).stat(target, signal);
1149
1009
  } catch (error) {
1150
- if (!(error instanceof FsError) || error.code !== "FS_NOT_FOUND") throw error;
1151
- const canonical = await this.canonicalTarget(target, signal);
1152
- if (canonical === undefined) throw error;
1153
- return operation(canonical);
1154
- }
1155
- // `stat` reports an absent file by RETURNING undefined rather than throwing,
1156
- // and an untraversable symlink takes exactly that path over this share. The
1157
- // model-facing read tool branches on precisely that undefined — it resolves,
1158
- // stats, and renders "not found" itself — so retrying only the throw is not
1159
- // enough: the silent miss has to be retried here too.
1160
- if (value === undefined) {
1161
- const canonical = await this.canonicalTarget(target, signal);
1162
- if (canonical !== undefined) return operation(canonical);
1163
- }
1164
- return value;
1165
- }
1166
-
1167
- async stat(target, signal) {
1168
- const sub = await this.agentSubstrate();
1169
- if (sub !== undefined) {
1170
- try {
1171
- return await sub.stat(target, signal);
1172
- } catch (error) {
1173
- throw this.peerError(error);
1174
- }
1010
+ throw this.peerError(error);
1175
1011
  }
1176
- return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
1177
1012
  }
1178
1013
 
1179
1014
  /**
1180
1015
  * No-follow stat one path. The agent substrate answers from the distro,
1181
- * where a symlink is a first-class entry; the share backend keeps the
1182
- * inherited host behaviour.
1016
+ * where a symlink is a first-class entry.
1183
1017
  * @param path - a Linux, UNC or Windows path.
1184
1018
  * @param opts - optional `cwd` override.
1185
1019
  * @param signal - optional cancellation.
1186
1020
  * @returns the path info, or undefined when absent.
1187
1021
  */
1188
1022
  async lstat(path, opts, signal) {
1189
- const sub = await this.agentSubstrate();
1190
- if (sub !== undefined) {
1191
- const world = await this.worldPath(path, opts?.cwd);
1192
- const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1193
- if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1194
- try {
1195
- return await sub.lstat(linux, signal);
1196
- } catch (error) {
1197
- throw this.peerError(error);
1198
- }
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);
1199
1030
  }
1200
- return super.lstat(path, opts, signal);
1201
1031
  }
1202
1032
 
1203
1033
  async readText(target, signal) {
1204
- const sub = await this.agentSubstrate();
1205
- if (sub !== undefined) {
1206
- try {
1207
- return await sub.readText(target, signal);
1208
- } catch (error) {
1209
- throw this.peerError(error);
1210
- }
1034
+ try {
1035
+ return await (await this.agentSubstrate()).readText(target, signal);
1036
+ } catch (error) {
1037
+ throw this.peerError(error);
1211
1038
  }
1212
- return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
1213
1039
  }
1214
1040
 
1215
1041
  async streamText(target, signal) {
1216
- const sub = await this.agentSubstrate();
1217
- if (sub !== undefined) {
1218
- try {
1219
- return await sub.streamText(target, signal);
1220
- } catch (error) {
1221
- throw this.peerError(error);
1222
- }
1042
+ try {
1043
+ return await (await this.agentSubstrate()).streamText(target, signal);
1044
+ } catch (error) {
1045
+ throw this.peerError(error);
1223
1046
  }
1224
- return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
1225
1047
  }
1226
1048
 
1227
1049
  async readBytes(target, signal, maxBytes) {
1228
- const sub = await this.agentSubstrate();
1229
- if (sub !== undefined) {
1230
- try {
1231
- return await sub.readBytes(target, signal, maxBytes);
1232
- } catch (error) {
1233
- throw this.peerError(error);
1234
- }
1050
+ try {
1051
+ return await (await this.agentSubstrate()).readBytes(target, signal, maxBytes);
1052
+ } catch (error) {
1053
+ throw this.peerError(error);
1235
1054
  }
1236
- return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
1237
1055
  }
1238
1056
 
1239
1057
  async readByteRange(target, range, signal) {
1240
- const sub = await this.agentSubstrate();
1241
- if (sub !== undefined) {
1242
- try {
1243
- return await sub.readByteRange(target, range, signal);
1244
- } catch (error) {
1245
- throw this.peerError(error);
1246
- }
1058
+ try {
1059
+ return await (await this.agentSubstrate()).readByteRange(target, range, signal);
1060
+ } catch (error) {
1061
+ throw this.peerError(error);
1247
1062
  }
1248
- return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
1249
1063
  }
1250
1064
 
1251
1065
  /**
1252
- * List one level, rewriting each child's display path into the distro's
1253
- * coordinate system — the host backend reports children with UNC display paths,
1254
- * which would leak `\\wsl.localhost\...` into model-facing output.
1255
- *
1066
+ * List one directory level. The substrate's rows already carry Linux display
1067
+ * paths and canonical identities — nothing to rewrite.
1256
1068
  * @param target - the resolved directory.
1257
1069
  * @param signal - optional cancellation.
1258
1070
  * @returns the directory rows.
1259
1071
  */
1260
1072
  async listDir(target, signal) {
1261
- const sub = await this.agentSubstrate();
1262
- if (sub !== undefined) {
1263
- // The substrate's rows already carry Linux display paths and canonical
1264
- // identities, so no share-side rewrite follows.
1265
- try {
1266
- return await sub.listDir(target, signal);
1267
- } catch (error) {
1268
- throw this.peerError(error);
1269
- }
1073
+ try {
1074
+ return await (await this.agentSubstrate()).listDir(target, signal);
1075
+ } catch (error) {
1076
+ throw this.peerError(error);
1270
1077
  }
1271
- const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
1272
- const distro = await this.distro();
1273
- return entries.map((entry) => ({
1274
- ...entry,
1275
- target: { targetKey: entry.target.targetKey, displayPath: toDisplayPath(entry.target.targetKey, distro) },
1276
- }));
1277
1078
  }
1278
1079
 
1279
1080
  /**
@@ -1322,22 +1123,12 @@ export class WslFileSystem extends LocalFileSystem {
1322
1123
  }
1323
1124
 
1324
1125
  /**
1325
- * Check the caller's mutation guard immediately before publication.
1326
- * @param target - the resolved target.
1327
- * @param expected - the write intent, or undefined for an unconditional write.
1328
- * @param signal - optional cancellation.
1329
- * @throws FsError with the same codes the host backend raises.
1330
- */
1331
- async assertGuard(target, expected, signal) {
1332
- if (expected === undefined) return;
1333
- const existing = await this.stat(target, signal);
1334
- const refusal = guardRefusal(expected, existing, target.displayPath);
1335
- if (refusal !== null) throw new FsError(refusal.message, refusal.code);
1336
- }
1337
-
1338
- /**
1339
- * Fence the write by the per-call policy, then keep the guard's observable
1340
- * 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
+ *
1341
1132
  * @param target - the resolved target.
1342
1133
  * @param content - the full new file content.
1343
1134
  * @param expected - the write intent.
@@ -1347,35 +1138,19 @@ export class WslFileSystem extends LocalFileSystem {
1347
1138
  * @returns the write outcome.
1348
1139
  */
1349
1140
  async writeText(target, content, expected, signal, sandboxPolicy) {
1350
- // The policy resolves once: the fence and the confined routing must see
1351
- // the same grant, or the kernel would enforce something other than what
1352
- // was checked.
1353
1141
  const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1354
- // The fence runs first and its fresh target is the one checked and written,
1355
- // so a refusal costs no I/O and cannot be raced by a swapped symlink.
1356
1142
  const fenced = await this.checkedTarget(target, policy);
1357
- const sub = await this.agentSubstrate();
1358
- if (sub !== undefined) {
1359
- // The agent substrate keeps the guard: the distro-side no-replace link
1360
- // is an atomic primitive, so the check-then-write window the share
1361
- // backend documents closes here, exactly as on a Linux host. The policy
1362
- // also selects the confined resident whose mount table matches it.
1363
- try {
1364
- return await sub.writeText(fenced, content, expected, signal, policy);
1365
- } catch (error) {
1366
- throw this.peerError(error);
1367
- }
1143
+ try {
1144
+ return await (await this.agentSubstrate()).writeText(fenced, content, expected, signal, policy);
1145
+ } catch (error) {
1146
+ throw this.peerError(error);
1368
1147
  }
1369
- await this.assertGuard(fenced, expected, signal);
1370
- // The guard is deliberately not forwarded: the host backend would publish a
1371
- // guarded creation with a hard link, which this filesystem rejects. Once
1372
- // past the guard, publication is the overridden `publish` (see the
1373
- // constructor), not the inherited Windows one.
1374
- return super.writeText(fenced, content, undefined, signal);
1375
1148
  }
1376
1149
 
1377
1150
  /**
1378
- * 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
+ *
1379
1154
  * @param target - the resolved target.
1380
1155
  * @param edit - the literal search/replace request.
1381
1156
  * @param expected - the version guard.
@@ -1387,15 +1162,10 @@ export class WslFileSystem extends LocalFileSystem {
1387
1162
  async editText(target, edit, expected, signal, sandboxPolicy) {
1388
1163
  const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1389
1164
  const fenced = await this.checkedTarget(target, policy);
1390
- const sub = await this.agentSubstrate();
1391
- if (sub !== undefined) {
1392
- try {
1393
- return await sub.editText(fenced, edit, expected, signal, policy);
1394
- } catch (error) {
1395
- throw this.peerError(error);
1396
- }
1165
+ try {
1166
+ return await (await this.agentSubstrate()).editText(fenced, edit, expected, signal, policy);
1167
+ } catch (error) {
1168
+ throw this.peerError(error);
1397
1169
  }
1398
- await this.assertGuard(fenced, expected, signal);
1399
- return super.editText(fenced, edit, undefined, signal);
1400
1170
  }
1401
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
+ }