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
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The distro-side filesystem substrate: fsio's orchestration, run over the
|
|
3
|
+
* agent's FS frames instead of Node's fs on the 9p share.
|
|
4
|
+
*
|
|
5
|
+
* Where `@deepseek-ai/dsh-fs-local` opens paths on the host, this module asks
|
|
6
|
+
* the resident in-distro agent (`agent/wsl-agent.sh`) to stat, list, read and
|
|
7
|
+
* write on ext4 — where symlinks resolve, mode bits stick, and `find` walks
|
|
8
|
+
* the native filesystem. Everything the peer does HOST-side after the bytes
|
|
9
|
+
* arrive stays here, byte-for-byte: the binary NUL sample, the fatal UTF-8
|
|
10
|
+
* decode, the LF normalization, the edit algorithm (see `fsio-text.js`).
|
|
11
|
+
*
|
|
12
|
+
* Coordinate systems: every method takes and returns LINUX paths; `targetKey`
|
|
13
|
+
* identity remains the distro's UNC form (the whole harness keys caches and
|
|
14
|
+
* guards on it), translated at this boundary via `paths.js`. The module is
|
|
15
|
+
* policy-free — the fence lives in the provider — and peer-free, so it is
|
|
16
|
+
* unit-testable in a bare checkout against a scripted agent.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-plugin-wsl/fsio-agent
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { FS_READ_CHUNK_BYTES } from "./agent-protocol.js";
|
|
22
|
+
import {
|
|
23
|
+
BINARY_SAMPLE_BYTES,
|
|
24
|
+
FsCodedError,
|
|
25
|
+
decodeUtf8,
|
|
26
|
+
decodeUtf8Stream,
|
|
27
|
+
detectLineEndings,
|
|
28
|
+
normalizeLineEndings,
|
|
29
|
+
restoreLineEndings,
|
|
30
|
+
applyLiteralEdit,
|
|
31
|
+
throwIfAborted,
|
|
32
|
+
} from "./fsio-text.js";
|
|
33
|
+
import { posixToUnc, uncToPosix } from "./paths.js";
|
|
34
|
+
|
|
35
|
+
/** One FS frame's in-distro watchdog; fs ops are short, writes bounded by content size. */
|
|
36
|
+
const FS_OP_TIMEOUT_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize one agent timestamp ("seconds.frac", 9 or 10 fractional digits
|
|
40
|
+
* depending on whether stat or find produced it) into the peer's nanosecond
|
|
41
|
+
* string: exactly nine fractional digits, no dot.
|
|
42
|
+
* @param {string} value - the wire timestamp.
|
|
43
|
+
* @returns {string} seconds followed by nine fractional digits.
|
|
44
|
+
*/
|
|
45
|
+
export function nanoseconds(value) {
|
|
46
|
+
const [seconds, fraction = ""] = String(value).split(".");
|
|
47
|
+
return `${seconds}${fraction.padEnd(9, "0").slice(0, 9)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The peer's `versionOf`: dev, inode, size and both nanosecond timestamps.
|
|
52
|
+
* Self-consistency within this provider is what versions need; the ingredients
|
|
53
|
+
* come from one kernel, so a change in any byte of the file changes the string.
|
|
54
|
+
* @param {object} ingredients - the stat record's identity fields.
|
|
55
|
+
* @returns {string} the opaque version string.
|
|
56
|
+
*/
|
|
57
|
+
export function versionOf({ dev, ino, size, mtimeNs, ctimeNs }) {
|
|
58
|
+
return `${dev}:${ino}:${size}:${mtimeNs}:${ctimeNs}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse one stat record as the agent emits it:
|
|
63
|
+
* `type \t mode \t size \t dev \t ino \t mtime \t ctime`.
|
|
64
|
+
* @param {Buffer} stdout - the op's payload.
|
|
65
|
+
* @returns {{type: string, mode: number, size: number, dev: string, ino: string, version: string}}
|
|
66
|
+
*/
|
|
67
|
+
export function parseStatRecord(stdout) {
|
|
68
|
+
const [type, mode, size, dev, ino, mtime, ctime] = stdout.toString("utf8").split("\t");
|
|
69
|
+
return {
|
|
70
|
+
type,
|
|
71
|
+
mode: Number.parseInt(mode, 8) & 0o777,
|
|
72
|
+
size: Number(size),
|
|
73
|
+
dev,
|
|
74
|
+
ino,
|
|
75
|
+
version: versionOf({ dev, ino, size, mtimeNs: nanoseconds(mtime), ctimeNs: nanoseconds(ctime) }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const REASON_CODES = {
|
|
80
|
+
notfound: "FS_NOT_FOUND",
|
|
81
|
+
notdir: "FS_NOT_FOUND",
|
|
82
|
+
perm: "FS_IO_ERROR",
|
|
83
|
+
loop: "FS_IO_ERROR",
|
|
84
|
+
exists: "FS_NOT_OBSERVED",
|
|
85
|
+
stale: "FS_STALE_VERSION",
|
|
86
|
+
io: "FS_IO_ERROR",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The kernel's denial dialect: a write that a read-only bind refused. The
|
|
91
|
+
* command path classifies the same signature into its sandbox denial, so a
|
|
92
|
+
* confined write the mount table refused carries `FS_SANDBOX_DENIED` — the
|
|
93
|
+
* model sees the escalation offer instead of an unexplained I/O failure.
|
|
94
|
+
*/
|
|
95
|
+
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One distro's filesystem face. Owns the agent round trips and the host-side
|
|
99
|
+
* validation; the provider layers the fence on top.
|
|
100
|
+
*/
|
|
101
|
+
export class DistroFs {
|
|
102
|
+
/**
|
|
103
|
+
* @param {object} deps - the substrate's collaborators.
|
|
104
|
+
* @param {import("./agent.js").WslAgent} deps.agent - the resident in-distro agent.
|
|
105
|
+
* @param {string} deps.distro - the pinned distro name, for UNC translation.
|
|
106
|
+
*/
|
|
107
|
+
constructor({ agent, distro }) {
|
|
108
|
+
this.agent = agent;
|
|
109
|
+
this.distro = distro;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run one FS op, mapping an aborted request onto the structured code the
|
|
114
|
+
* provider's callers expect. A mutation may name the agent that must run it
|
|
115
|
+
* — the confined resident whose mount table matches the granted rights —
|
|
116
|
+
* while reads ride the plain resident.
|
|
117
|
+
* @private
|
|
118
|
+
*/
|
|
119
|
+
async request(op, args, signal, agent = this.agent) {
|
|
120
|
+
try {
|
|
121
|
+
return await agent.fs({ op, args, timeoutMs: FS_OP_TIMEOUT_MS, signal });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (signal?.aborted) {
|
|
124
|
+
throw new FsCodedError(`${op === "write" ? "write" : "read"} aborted`, "FS_ABORTED", error instanceof Error ? error : undefined);
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Turn a failed op into the coded error, reading the agent's
|
|
132
|
+
* `dsh-fs|<reason>|<b64 message>` line; a frame without one is a raw I/O
|
|
133
|
+
* fault carrying whatever stderr the agent did produce. On a WRITE, the
|
|
134
|
+
* kernel's read-only-bind denial is classified as the sandbox refusal it is,
|
|
135
|
+
* mirroring the command path's denial signatures.
|
|
136
|
+
* @private
|
|
137
|
+
*/
|
|
138
|
+
fail(op, result, displayPath) {
|
|
139
|
+
const text = result.stderr.toString("utf8");
|
|
140
|
+
const first = text.split("\n")[0] ?? "";
|
|
141
|
+
if (first.startsWith("dsh-fs|")) {
|
|
142
|
+
const [, reason, encoded] = first.split("|");
|
|
143
|
+
const message = Buffer.from(encoded ?? "", "base64").toString("utf8");
|
|
144
|
+
if (op === "write" && DENIAL_SIGNATURES.some((signature) => message.toLowerCase().includes(signature))) {
|
|
145
|
+
return new FsCodedError(`cannot write "${displayPath}": ${message}`, "FS_SANDBOX_DENIED");
|
|
146
|
+
}
|
|
147
|
+
return new FsCodedError(message, REASON_CODES[reason] ?? "FS_IO_ERROR");
|
|
148
|
+
}
|
|
149
|
+
return new FsCodedError(`cannot ${op} "${displayPath}": ${text.trim() || "distro I/O failure"}`, "FS_IO_ERROR");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stat one Linux path, following or not. A missing path is `null`, exactly
|
|
154
|
+
* like the peer's `probe`; everything else that is not found-class is a
|
|
155
|
+
* structured failure.
|
|
156
|
+
* @param {string} linuxPath - the path inside the distro.
|
|
157
|
+
* @param {object} [options] - the stat flavour.
|
|
158
|
+
* @param {boolean} [options.follow] - follow symlinks (default true).
|
|
159
|
+
* @param {AbortSignal} [options.signal] - cancels the round trip.
|
|
160
|
+
* @returns {Promise<{type: string, mode: number, size: number, version: string}|null>}
|
|
161
|
+
*/
|
|
162
|
+
async stat(linuxPath, { follow = true, signal } = {}) {
|
|
163
|
+
throwIfAborted(signal, "stat");
|
|
164
|
+
const result = await this.request(follow ? "stat" : "lstat", [linuxPath], signal);
|
|
165
|
+
if (result.exitCode === 0) return parseStatRecord(result.stdout);
|
|
166
|
+
const failure = this.fail("stat", result, linuxPath);
|
|
167
|
+
if (failure.code === "FS_NOT_FOUND") return null;
|
|
168
|
+
throw failure;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The distro-side identity of one path: the strict realpath, or on a missing
|
|
173
|
+
* target the nearest existing ancestor with the missing suffix — the peer's
|
|
174
|
+
* `resolveLocalTarget`, run where the symlinks live.
|
|
175
|
+
* @param {string} linuxPath - the absolute Linux path to resolve.
|
|
176
|
+
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
177
|
+
* @returns {Promise<string>} the canonical Linux path.
|
|
178
|
+
*/
|
|
179
|
+
async canonicalPath(linuxPath, signal) {
|
|
180
|
+
throwIfAborted(signal, "resolve");
|
|
181
|
+
if (String(linuxPath).trim().length === 0) {
|
|
182
|
+
throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
183
|
+
}
|
|
184
|
+
const result = await this.request("realpath", [linuxPath], signal);
|
|
185
|
+
if (result.exitCode !== 0) throw this.fail("resolve", result, linuxPath);
|
|
186
|
+
return result.stdout.toString("utf8");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolve one Linux path to the target shape the provider and tooling use:
|
|
191
|
+
* the Linux spelling the model sees and the UNC identity the harness keys on.
|
|
192
|
+
* @param {string} linuxPath - the absolute Linux path to resolve.
|
|
193
|
+
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
194
|
+
* @returns {Promise<{displayPath: string, targetKey: string}>}
|
|
195
|
+
*/
|
|
196
|
+
async resolveTarget(linuxPath, signal) {
|
|
197
|
+
const canonical = await this.canonicalPath(linuxPath, signal);
|
|
198
|
+
return { displayPath: linuxPath, targetKey: posixToUnc(this.distro, canonical) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* List one directory's direct children in name order — one `find -L` pass in
|
|
203
|
+
* the distro, sorted and shaped host-side like the peer's `listDirectory`.
|
|
204
|
+
* A symlink child's identity is resolved with one extra round trip, because
|
|
205
|
+
* the follow-stat the list already carried belongs to its target.
|
|
206
|
+
* @param {object} target - the resolved directory to list.
|
|
207
|
+
* @param {string} target.displayPath - the Linux directory spelling.
|
|
208
|
+
* @param {string} target.targetKey - the directory's UNC identity (canonical).
|
|
209
|
+
* @param {AbortSignal} [signal] - aborts between children.
|
|
210
|
+
* @returns {Promise<Array<{name: string, type: string, target: {displayPath: string, targetKey: string}, version?: string, size?: number}>>}
|
|
211
|
+
*/
|
|
212
|
+
/**
|
|
213
|
+
* List one directory's direct children in name order — one `find` pass in
|
|
214
|
+
* the distro, sorted and shaped host-side like the peer's `listDirectory`.
|
|
215
|
+
* The wire type is the entry's own (lstat): a symlink child then gets one
|
|
216
|
+
* follow-up stat (its target's version and size) plus a realpath (its
|
|
217
|
+
* identity), and a dangling symlink degrades to the peer's `other` with no
|
|
218
|
+
* version — exactly what the peer's null follow-probe produces.
|
|
219
|
+
* @param {object} target - the resolved directory to list.
|
|
220
|
+
* @param {string} target.displayPath - the Linux directory spelling.
|
|
221
|
+
* @param {string} target.targetKey - the directory's UNC identity (canonical).
|
|
222
|
+
* @param {AbortSignal} [signal] - aborts between children.
|
|
223
|
+
* @returns {Promise<Array<{name: string, type: string, target: {displayPath: string, targetKey: string}, version?: string, size?: number}>>}
|
|
224
|
+
*/
|
|
225
|
+
async listChildren(target, signal) {
|
|
226
|
+
throwIfAborted(signal, "list");
|
|
227
|
+
const linuxDir = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
|
|
228
|
+
const info = await this.stat(linuxDir, { signal });
|
|
229
|
+
if (!info) throw new FsCodedError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
230
|
+
if (info.type !== "d") throw new FsCodedError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY");
|
|
231
|
+
const result = await this.request("list", [linuxDir], signal);
|
|
232
|
+
if (result.exitCode !== 0) throw this.fail("list", result, target.displayPath);
|
|
233
|
+
const parentCanonical = linuxDir;
|
|
234
|
+
const records = result.stdout.toString("utf8").split("\0").filter((record) => record.length > 0);
|
|
235
|
+
const entries = [];
|
|
236
|
+
for (const record of records) {
|
|
237
|
+
throwIfAborted(signal, "list");
|
|
238
|
+
const fields = record.split("\t");
|
|
239
|
+
const wireType = fields[0];
|
|
240
|
+
const [, , dev, ino, mtime, ctime] = fields;
|
|
241
|
+
const path = fields.slice(6).join("\t");
|
|
242
|
+
// `find` joins each child onto the parent path. A root listing's parent
|
|
243
|
+
// is bare "/", where `slice(length + 1)` sheared the first character off
|
|
244
|
+
// EVERY name ("/etc" -> "tc") and the next write then created "/tc" — so
|
|
245
|
+
// strip the exact prefix plus any separators instead. The join below
|
|
246
|
+
// then owes its own slash: an empty spelling is what "/" contributes.
|
|
247
|
+
const directory = parentCanonical === "/" ? "" : parentCanonical;
|
|
248
|
+
const name = path.slice(parentCanonical.length).replace(/^\/+/, "");
|
|
249
|
+
const displayPath = `${directory}/${name}`;
|
|
250
|
+
// Non-link entries: the record's own stat IS the follow stat. A link
|
|
251
|
+
// costs one stat plus one realpath, where the symlink actually lives.
|
|
252
|
+
let { type, version, size } = { type: wireType, version: undefined, size: undefined };
|
|
253
|
+
let childCanonical = `${directory}/${name}`;
|
|
254
|
+
if (wireType === "l") {
|
|
255
|
+
const followed = await this.stat(childCanonical, { signal }).catch(() => null);
|
|
256
|
+
if (followed) {
|
|
257
|
+
type = followed.type;
|
|
258
|
+
version = followed.version;
|
|
259
|
+
if (followed.type === "f") size = followed.size;
|
|
260
|
+
childCanonical = await this.canonicalPath(childCanonical, signal);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
version = versionOf({ dev, ino, size: fields[1], mtimeNs: nanoseconds(mtime), ctimeNs: nanoseconds(ctime) });
|
|
264
|
+
if (type === "f") size = Number(fields[1]);
|
|
265
|
+
}
|
|
266
|
+
entries.push({
|
|
267
|
+
name,
|
|
268
|
+
type: type === "f" ? "file" : type === "d" ? "directory" : "other",
|
|
269
|
+
target: { displayPath, targetKey: posixToUnc(this.distro, childCanonical) },
|
|
270
|
+
...(version !== undefined ? { version } : {}),
|
|
271
|
+
...(size !== undefined ? { size } : {}),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Stat the target as a regular file or refuse, mirroring the peer's
|
|
279
|
+
* `statRegularFile` messages.
|
|
280
|
+
* @private
|
|
281
|
+
*/
|
|
282
|
+
async statRegularFile(target, verb, signal) {
|
|
283
|
+
throwIfAborted(signal, verb);
|
|
284
|
+
const info = await this.stat(uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath, { signal });
|
|
285
|
+
if (!info) throw new FsCodedError(`cannot ${verb} "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
286
|
+
if (info.type !== "f") throw new FsCodedError(`cannot ${verb} "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
287
|
+
return info;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Read the whole file's bytes through windowed frames.
|
|
292
|
+
* @private
|
|
293
|
+
*/
|
|
294
|
+
async readWhole(target, signal, maxBytes = Number.MAX_SAFE_INTEGER) {
|
|
295
|
+
const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
|
|
296
|
+
const chunks = [];
|
|
297
|
+
let offset = 0;
|
|
298
|
+
for (;;) {
|
|
299
|
+
throwIfAborted(signal, "read");
|
|
300
|
+
const result = await this.request("read", [linuxPath, String(offset), String(maxBytes === Number.MAX_SAFE_INTEGER ? FS_READ_CHUNK_BYTES : maxBytes - offset + 1)], signal);
|
|
301
|
+
if (result.exitCode !== 0) throw this.fail("read", result, target.displayPath);
|
|
302
|
+
const chunk = result.stdout;
|
|
303
|
+
if (chunk.length === 0) break;
|
|
304
|
+
chunks.push(chunk);
|
|
305
|
+
offset += chunk.length;
|
|
306
|
+
if (offset > maxBytes) {
|
|
307
|
+
throw new FsCodedError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
|
|
308
|
+
}
|
|
309
|
+
if (chunk.length < FS_READ_CHUNK_BYTES) break;
|
|
310
|
+
}
|
|
311
|
+
return Buffer.concat(chunks, offset);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Read a whole regular UTF-8 file, rejecting binaries and invalid UTF-8.
|
|
316
|
+
* @param {object} target - the resolved file.
|
|
317
|
+
* @param {AbortSignal} [signal] - aborts the read.
|
|
318
|
+
* @returns {Promise<string>} the full decoded text, byte-for-byte.
|
|
319
|
+
*/
|
|
320
|
+
async readWholeText(target, signal) {
|
|
321
|
+
await this.statRegularFile(target, "read", signal);
|
|
322
|
+
const raw = await this.readWhole(target, signal);
|
|
323
|
+
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
|
324
|
+
throw new FsCodedError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
|
|
325
|
+
}
|
|
326
|
+
return decodeUtf8(raw, "read", target.displayPath);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Read a whole regular file as raw bytes, bounded by `maxBytes`.
|
|
331
|
+
* @param {object} target - the resolved file.
|
|
332
|
+
* @param {AbortSignal|undefined} signal - aborts the read.
|
|
333
|
+
* @param {number} maxBytes - inclusive byte cap on the complete content.
|
|
334
|
+
* @returns {Promise<Uint8Array>} the full raw content, at most `maxBytes` long.
|
|
335
|
+
*/
|
|
336
|
+
async readWholeBytes(target, signal, maxBytes) {
|
|
337
|
+
const info = await this.statRegularFile(target, "read", signal);
|
|
338
|
+
if (info.size > maxBytes) {
|
|
339
|
+
throw new FsCodedError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
|
|
340
|
+
}
|
|
341
|
+
return this.readWhole(target, signal, maxBytes);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Read the bytes at `[offset, offset + length)` of a regular file. A window
|
|
346
|
+
* at or past the end is empty.
|
|
347
|
+
* @param {object} target - the resolved file.
|
|
348
|
+
* @param {{offset: number, length: number}} range - the byte window.
|
|
349
|
+
* @param {AbortSignal} [signal] - aborts the read.
|
|
350
|
+
* @returns {Promise<Uint8Array>} the window's bytes.
|
|
351
|
+
*/
|
|
352
|
+
async readByteWindow(target, range, signal) {
|
|
353
|
+
await this.statRegularFile(target, "read", signal);
|
|
354
|
+
if (range.length === 0) return new Uint8Array(0);
|
|
355
|
+
const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
|
|
356
|
+
const result = await this.request("read", [linuxPath, String(range.offset), String(range.length)], signal);
|
|
357
|
+
if (result.exitCode !== 0) throw this.fail("read", result, target.displayPath);
|
|
358
|
+
return result.stdout;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Stream a whole regular UTF-8 file as decoded text chunks — the binary
|
|
363
|
+
* sample scan and cross-chunk decoding stay host-side, exactly the peer's.
|
|
364
|
+
* @param {object} target - the resolved file.
|
|
365
|
+
* @param {AbortSignal} [signal] - aborts the stream.
|
|
366
|
+
* @returns {AsyncGenerator<string>} decoded text chunks in file order.
|
|
367
|
+
*/
|
|
368
|
+
async *streamWholeText(target, signal) {
|
|
369
|
+
await this.statRegularFile(target, "read", signal);
|
|
370
|
+
const linuxPath = uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
|
|
371
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
372
|
+
let sampledBytes = 0;
|
|
373
|
+
let offset = 0;
|
|
374
|
+
const scanBinarySample = (chunk) => {
|
|
375
|
+
if (sampledBytes >= BINARY_SAMPLE_BYTES) return;
|
|
376
|
+
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes));
|
|
377
|
+
if (sample.includes(0)) throw new FsCodedError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
|
|
378
|
+
sampledBytes += sample.length;
|
|
379
|
+
};
|
|
380
|
+
for (;;) {
|
|
381
|
+
throwIfAborted(signal, "read");
|
|
382
|
+
const result = await this.request("read", [linuxPath, String(offset), String(FS_READ_CHUNK_BYTES)], signal);
|
|
383
|
+
if (result.exitCode !== 0) throw this.fail("read", result, target.displayPath);
|
|
384
|
+
const chunk = result.stdout;
|
|
385
|
+
if (chunk.length === 0) break;
|
|
386
|
+
scanBinarySample(chunk);
|
|
387
|
+
yield decodeUtf8Stream(decoder, chunk, "read", target.displayPath);
|
|
388
|
+
offset += chunk.length;
|
|
389
|
+
if (chunk.length < FS_READ_CHUNK_BYTES) break;
|
|
390
|
+
}
|
|
391
|
+
yield decodeUtf8Stream(decoder, undefined, "read", target.displayPath);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Publish `content` over `linuxPath` the POSIX way: the agent stages into a
|
|
396
|
+
* private 0700 sibling, fsyncs, chmods, and renames — or hard links for a
|
|
397
|
+
* guarded create. A `replace` publish carrying `expectedVersion` is
|
|
398
|
+
* re-verified by the agent against a fresh stat one syscall before the
|
|
399
|
+
* rename: a concurrent writer (an editor save, a `git stash`) wins, and the
|
|
400
|
+
* stale write refuses with `FS_STALE_VERSION` instead of landing.
|
|
401
|
+
* @param {string} linuxPath - the target path inside the distro.
|
|
402
|
+
* @param {string|Uint8Array} content - the full new content.
|
|
403
|
+
* @param {object} [options]
|
|
404
|
+
* @param {number} [options.mode] - the mode to publish (omitted keeps the
|
|
405
|
+
* temp's 0600 for a create; the caller passes the incumbent's for an edit).
|
|
406
|
+
* @param {{displayPath: string}} [options.createIfAbsent] - guarded-create
|
|
407
|
+
* intent: publish with a no-replace link carrying this display path in errors.
|
|
408
|
+
* @param {string} [options.expectedVersion] - the version string this write
|
|
409
|
+
* was based on (a stat-derived one); re-verified distro-side before the rename.
|
|
410
|
+
* @param {AbortSignal} [options.signal] - aborts the write.
|
|
411
|
+
* @param {object} [options.agent] - the agent this mutation rides (the
|
|
412
|
+
* confined resident under a confined policy).
|
|
413
|
+
* @returns {Promise<void>} resolves when the publish landed.
|
|
414
|
+
*/
|
|
415
|
+
async writeFileAtomic(linuxPath, content, { mode, createIfAbsent, expectedVersion, signal, agent } = {}) {
|
|
416
|
+
throwIfAborted(signal, "write");
|
|
417
|
+
const displayPath = createIfAbsent?.displayPath ?? linuxPath;
|
|
418
|
+
const result = await this.request(
|
|
419
|
+
"write",
|
|
420
|
+
[
|
|
421
|
+
linuxPath,
|
|
422
|
+
mode === undefined ? "-" : (mode & 0o777).toString(8),
|
|
423
|
+
createIfAbsent ? "no-replace" : "replace",
|
|
424
|
+
expectedVersion ?? "-",
|
|
425
|
+
content,
|
|
426
|
+
],
|
|
427
|
+
signal,
|
|
428
|
+
agent,
|
|
429
|
+
);
|
|
430
|
+
if (result.exitCode === 0) return;
|
|
431
|
+
const failure = this.fail("write", result, displayPath);
|
|
432
|
+
if (failure.code !== "FS_NOT_OBSERVED") throw failure;
|
|
433
|
+
// The guarded create hit an existing target: classify it the way the
|
|
434
|
+
// peer's throwGuardedCreateFailure does — a non-regular incumbent is a
|
|
435
|
+
// different refusal than an unread file.
|
|
436
|
+
const incumbent = await this.stat(linuxPath, { follow: false, signal }).catch(() => null);
|
|
437
|
+
if (incumbent && incumbent.type !== "f") {
|
|
438
|
+
throw new FsCodedError(`cannot write "${displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE", failure);
|
|
439
|
+
}
|
|
440
|
+
throw new FsCodedError(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED", failure);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Read and decode a file for editing: LF-normalized content plus the style
|
|
445
|
+
* to restore on write-back.
|
|
446
|
+
* @param {object} target - the resolved file.
|
|
447
|
+
* @param {AbortSignal} [signal] - aborts the read.
|
|
448
|
+
* @returns {Promise<{content: string, lineEndings: "LF"|"CRLF"}>}
|
|
449
|
+
*/
|
|
450
|
+
async readForEdit(target, signal) {
|
|
451
|
+
const raw = await this.readWholeText(target, signal);
|
|
452
|
+
const lineEndings = detectLineEndings(raw);
|
|
453
|
+
return { content: normalizeLineEndings(raw), lineEndings };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Best-effort overwrite diff basis: `null` whenever the file cannot serve
|
|
458
|
+
* one (binary, at/above the bound, vanished), so the write still succeeds
|
|
459
|
+
* and presentation falls back to a whole-file diff.
|
|
460
|
+
* @param {object} target - the resolved file.
|
|
461
|
+
* @param {number} maxBytes - exclusive upper bound for the held basis.
|
|
462
|
+
* @param {AbortSignal} [signal] - cancellation propagates, unlike I/O failure.
|
|
463
|
+
* @returns {Promise<string|null>} the LF-normalized text, or null.
|
|
464
|
+
*/
|
|
465
|
+
async readTextForDiff(target, maxBytes, signal) {
|
|
466
|
+
throwIfAborted(signal, "read");
|
|
467
|
+
try {
|
|
468
|
+
const info = await this.stat(uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath, { signal });
|
|
469
|
+
if (!info || info.type !== "f" || info.size >= maxBytes) return null;
|
|
470
|
+
const raw = await this.readWhole(target, signal, maxBytes - 1);
|
|
471
|
+
if (raw.includes(0)) return null;
|
|
472
|
+
return normalizeLineEndings(decodeUtf8(raw, "read", target.displayPath));
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (error instanceof FsCodedError && error.code === "FS_ABORTED") throw error;
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export { applyLiteralEdit, normalizeLineEndings, restoreLineEndings };
|
|
481
|
+
export default DistroFs;
|
package/lib/fsio-text.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text mechanics of the distro-side filesystem, replicated from
|
|
3
|
+
* `@deepseek-ai/dsh-fs-local`'s fsio module so `lib/fsio-agent.js` can run the
|
|
4
|
+
* same orchestration without importing the peer.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is pure and validation-only: no I/O, no Node fs. The
|
|
7
|
+
* replication is deliberate and temporary — once the upstream package exports
|
|
8
|
+
* its fsio module (the export is proposed in docs/UPSTREAM-FSIO-EXPORT.md),
|
|
9
|
+
* these functions are deleted in favour of the imported originals. Until then
|
|
10
|
+
* they are kept byte-for-byte faithful, including error messages, because the
|
|
11
|
+
* tool layer matches on both the `code` and what the model reads — and that
|
|
12
|
+
* faithfulness is verified, not assumed: docs/PEER-PARITY.md records the
|
|
13
|
+
* function-by-function comparison against the pinned peer's shipped code.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-wsl/fsio-text
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The byte prefix scanned for a NUL before a read is declared binary. */
|
|
19
|
+
export const BINARY_SAMPLE_BYTES = 8192;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A coded filesystem failure. `code` carries the `FS_*` token the provider
|
|
23
|
+
* maps onto the peer's `FsError`; the class stays local so this module imports
|
|
24
|
+
* no peer and stays unit-testable in a bare checkout.
|
|
25
|
+
*/
|
|
26
|
+
export class FsCodedError extends Error {
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} message - human-readable, model-facing.
|
|
29
|
+
* @param {string} code - the `FS_*` token.
|
|
30
|
+
* @param {Error} [cause] - the underlying failure, when there is one.
|
|
31
|
+
*/
|
|
32
|
+
constructor(message, code, cause) {
|
|
33
|
+
super(message, cause === undefined ? {} : { cause });
|
|
34
|
+
this.name = "FsCodedError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} verb - the operation being attempted, for the message.
|
|
41
|
+
* @param {string} displayPath - the caller-facing path.
|
|
42
|
+
* @returns {FsCodedError} the structured not-text failure.
|
|
43
|
+
*/
|
|
44
|
+
export function notTextError(verb, displayPath) {
|
|
45
|
+
return new FsCodedError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Decode a buffer as strict UTF-8, rejecting invalid byte sequences the way
|
|
50
|
+
* the peer's fsio does.
|
|
51
|
+
* @param {Buffer} buffer - the bytes to decode.
|
|
52
|
+
* @param {string} verb - the operation being attempted, for the message.
|
|
53
|
+
* @param {string} displayPath - the caller-facing path.
|
|
54
|
+
* @returns {string} the decoded text.
|
|
55
|
+
* @throws {FsCodedError} with `FS_NOT_TEXT` on invalid UTF-8.
|
|
56
|
+
*/
|
|
57
|
+
export function decodeUtf8(buffer, verb, displayPath) {
|
|
58
|
+
try {
|
|
59
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof TypeError)) throw error;
|
|
62
|
+
throw notTextError(verb, displayPath);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decode one streamed chunk, carrying multi-byte sequences across chunks.
|
|
68
|
+
* @param {TextDecoder} decoder - the streaming decoder owned by the caller.
|
|
69
|
+
* @param {Buffer|undefined} chunk - the next chunk, or `undefined` to flush.
|
|
70
|
+
* @param {string} verb - the operation being attempted, for the message.
|
|
71
|
+
* @param {string} displayPath - the caller-facing path.
|
|
72
|
+
* @returns {string} the decoded text.
|
|
73
|
+
* @throws {FsCodedError} with `FS_NOT_TEXT` on invalid UTF-8.
|
|
74
|
+
*/
|
|
75
|
+
export function decodeUtf8Stream(decoder, chunk, verb, displayPath) {
|
|
76
|
+
try {
|
|
77
|
+
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!(error instanceof TypeError)) throw error;
|
|
80
|
+
throw notTextError(verb, displayPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Collapse CRLF to LF — the canonical in-memory form every edit/diff basis uses. */
|
|
85
|
+
export function normalizeLineEndings(content) {
|
|
86
|
+
return content.replaceAll("\r\n", "\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {string} raw - decoded text in whatever line-ending style the file had. */
|
|
90
|
+
export function detectLineEndings(raw) {
|
|
91
|
+
const sample = raw.slice(0, 4096);
|
|
92
|
+
const crlfCount = sample.split("\r\n").length - 1;
|
|
93
|
+
return crlfCount > sample.split("\n").length - 1 - crlfCount ? "CRLF" : "LF";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Convert LF-normalized content back to the style detected at read time.
|
|
98
|
+
* @param {string} content - the LF-normalized (edited) text.
|
|
99
|
+
* @param {"LF"|"CRLF"} lineEndings - the original file's style.
|
|
100
|
+
* @returns {string} the content in the original file's line-ending style.
|
|
101
|
+
*/
|
|
102
|
+
export function restoreLineEndings(content, lineEndings) {
|
|
103
|
+
if (lineEndings === "LF") return content;
|
|
104
|
+
return normalizeLineEndings(content).replaceAll("\n", "\r\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function countOccurrences(content, search) {
|
|
108
|
+
return content.split(search).length - 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Apply a literal replacement to LF-normalized content — the peer's edit
|
|
113
|
+
* algorithm, replicated: CRLF inside the needle is normalized before
|
|
114
|
+
* matching, an empty needle is refused, and a multi-match needle is ambiguous
|
|
115
|
+
* unless `replaceAll` is set.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} content - the current file content, already LF-normalized.
|
|
118
|
+
* @param {string} oldString - literal text to find.
|
|
119
|
+
* @param {string} newString - literal replacement text.
|
|
120
|
+
* @param {boolean} replaceAll - replace every match instead of requiring one.
|
|
121
|
+
* @param {string} displayPath - the caller-facing path for error messages.
|
|
122
|
+
* @returns {{content: string, replacements: number}} the edited text and count.
|
|
123
|
+
* @throws {FsCodedError} with `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT`.
|
|
124
|
+
*/
|
|
125
|
+
export function applyLiteralEdit(content, oldString, newString, replaceAll, displayPath) {
|
|
126
|
+
const oldNorm = normalizeLineEndings(oldString);
|
|
127
|
+
if (oldNorm.length === 0) throw new FsCodedError("old_string must be a non-empty string", "FS_EDIT_NOT_FOUND");
|
|
128
|
+
const newNorm = normalizeLineEndings(newString);
|
|
129
|
+
const replacements = countOccurrences(content, oldNorm);
|
|
130
|
+
if (replacements === 0) throw new FsCodedError(`old_string was not found in "${displayPath}"`, "FS_EDIT_NOT_FOUND");
|
|
131
|
+
if (!replaceAll && replacements > 1) {
|
|
132
|
+
throw new FsCodedError(
|
|
133
|
+
`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`,
|
|
134
|
+
"FS_AMBIGUOUS_EDIT",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
content: content.split(oldNorm).join(newNorm),
|
|
139
|
+
replacements,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Abort boundary mirroring the peer's `throwIfAborted`.
|
|
145
|
+
* @param {AbortSignal|undefined} signal - the caller's signal.
|
|
146
|
+
* @param {string} verb - the operation being attempted, for the message.
|
|
147
|
+
* @throws {FsCodedError} with `FS_ABORTED` when the signal is done.
|
|
148
|
+
*/
|
|
149
|
+
export function throwIfAborted(signal, verb) {
|
|
150
|
+
if (signal?.aborted) {
|
|
151
|
+
throw new FsCodedError(`${verb} aborted`, "FS_ABORTED", signal.reason instanceof Error ? signal.reason : undefined);
|
|
152
|
+
}
|
|
153
|
+
}
|