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,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-backed filesystem substrate: the provider-shaped operations the
|
|
3
|
+
* model's file tools call, orchestrated over the distro's FS frames.
|
|
4
|
+
*
|
|
5
|
+
* This is the `ctx.fs` seam's mechanics for `substrate: "agent"` — resolve,
|
|
6
|
+
* stat, reads, and the write/edit orchestration the host backend owns on a
|
|
7
|
+
* share-backed session (probe, guards, diff basis, publication, outcome
|
|
8
|
+
* shape), mirroring `@deepseek-ai/dsh-fs-local`'s `writeText` and `editText`
|
|
9
|
+
* byte-for-byte in message and code. Two deliberate differences from the share
|
|
10
|
+
* backend it replaces:
|
|
11
|
+
*
|
|
12
|
+
* 1. **The mutation guard survives to the write — per intent.** A guarded
|
|
13
|
+
* create publishes with the distro-side no-replace link, a real atomic
|
|
14
|
+
* primitive that refuses to replace. An overwrite or edit carries the
|
|
15
|
+
* version it was based on INTO the write op, and the agent re-verifies it
|
|
16
|
+
* with a fresh stat one syscall before the rename: a concurrent writer
|
|
17
|
+
* (an editor save, `git stash`, another session) wins, and the stale
|
|
18
|
+
* write refuses with `FS_STALE_VERSION` instead of clobbering. That
|
|
19
|
+
* narrows the share backend's check-then-write window from a host round
|
|
20
|
+
* trip to kernel-adjacent — the honest wording; "closed" was true only
|
|
21
|
+
* for the create case.
|
|
22
|
+
* 2. A symlink needs no retry machinery: every identity is resolved where the
|
|
23
|
+
* symlinks live.
|
|
24
|
+
*
|
|
25
|
+
* Peer-free by construction: refusals are `FsCodedError`s from
|
|
26
|
+
* `lib/fsio-text.js`, mapped onto the peer's `FsError` at the provider
|
|
27
|
+
* boundary, so this module is unit-testable in a bare checkout against the
|
|
28
|
+
* real agent script.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-plugin-wsl/fs-substrate
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { applyLiteralEdit, FsCodedError, normalizeLineEndings, restoreLineEndings } from "./fsio-text.js";
|
|
34
|
+
import { DistroFs } from "./fsio-agent.js";
|
|
35
|
+
import { posixToUnc, uncToPosix } from "./paths.js";
|
|
36
|
+
|
|
37
|
+
/** The provider's stat/lstat row, type-spelled the way the tool layer reads it. */
|
|
38
|
+
function toInfo({ version, type, size }) {
|
|
39
|
+
return {
|
|
40
|
+
version,
|
|
41
|
+
type: type === "f" ? "file" : type === "d" ? "directory" : type === "l" ? "symlink" : "other",
|
|
42
|
+
size,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One distro's provider-shaped filesystem face.
|
|
48
|
+
*/
|
|
49
|
+
export class AgentSubstrate {
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} deps - the substrate's collaborators.
|
|
52
|
+
* @param {import("./agent.js").WslAgent} deps.agent - the resident in-distro agent reads ride.
|
|
53
|
+
* @param {string} deps.distro - the pinned distro name, for UNC identity.
|
|
54
|
+
* @param {number} [deps.diffBasisMaxBytes] - the overwrite-diff bound, mirroring the provider config.
|
|
55
|
+
* @param {(policy: {mode: string, workspaceRoot: string}|undefined) => Promise<import("./agent.js").WslAgent>} [deps.agentFor]
|
|
56
|
+
* stage-two routing: the agent a MUTATION must run on for this resolved
|
|
57
|
+
* policy — the confined resident whose mount table matches the granted
|
|
58
|
+
* rights. Omitted (or resolving to `deps.agent`) keeps every op on the
|
|
59
|
+
* plain resident, which is stage one's posture.
|
|
60
|
+
*/
|
|
61
|
+
constructor({ agent, agentFor, distro, diffBasisMaxBytes = 10 * 1024 * 1024 }) {
|
|
62
|
+
this.distro = distro;
|
|
63
|
+
this.diffBasisMaxBytes = diffBasisMaxBytes;
|
|
64
|
+
this.agent = agent;
|
|
65
|
+
this.agentFor = agentFor;
|
|
66
|
+
this.fs = new DistroFs({ agent, distro });
|
|
67
|
+
/** Per-targetKey tail promise: the host backend's mutation serialization. */
|
|
68
|
+
this.locks = new Map();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The agent a mutation runs on under this resolved policy.
|
|
73
|
+
* @param {{mode: string, workspaceRoot: string}|undefined} policy - the
|
|
74
|
+
* resolved file-effect policy, or undefined when the caller pre-checked.
|
|
75
|
+
* @returns {Promise<object>} the agent to publish through.
|
|
76
|
+
*/
|
|
77
|
+
async mutationAgent(policy) {
|
|
78
|
+
if (this.agentFor === undefined) return this.agent;
|
|
79
|
+
return this.agentFor(policy);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
|
83
|
+
async lock(targetKey, op) {
|
|
84
|
+
const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(op, op);
|
|
85
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
86
|
+
this.locks.set(targetKey, tail);
|
|
87
|
+
try {
|
|
88
|
+
return await run;
|
|
89
|
+
} finally {
|
|
90
|
+
if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The Linux path a resolved target names, falling back to its display. */
|
|
95
|
+
linuxOf(target) {
|
|
96
|
+
return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve one absolute Linux path to the target shape the provider returns:
|
|
101
|
+
* the canonical Linux spelling the model sees and the UNC identity the
|
|
102
|
+
* harness keys on. Symlinks resolve here, where they live.
|
|
103
|
+
* @param {string} linuxPath - the absolute Linux path to resolve.
|
|
104
|
+
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
105
|
+
* @returns {Promise<{displayPath: string, targetKey: string}>}
|
|
106
|
+
*/
|
|
107
|
+
async resolve(linuxPath, signal) {
|
|
108
|
+
const canonical = await this.fs.canonicalPath(linuxPath, signal);
|
|
109
|
+
return { displayPath: canonical, targetKey: posixToUnc(this.distro, canonical) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Follow-stat one target; `undefined` when absent.
|
|
114
|
+
* @param {object} target - the resolved target.
|
|
115
|
+
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
116
|
+
* @returns {Promise<{version: string, type: string, size: number}|undefined>}
|
|
117
|
+
*/
|
|
118
|
+
async stat(target, signal) {
|
|
119
|
+
const info = await this.fs.stat(this.linuxOf(target), { signal });
|
|
120
|
+
return info === null ? undefined : toInfo(info);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* No-follow stat one path; `undefined` when absent, `symlink` for a link.
|
|
125
|
+
* @param {string} linuxPath - the absolute Linux path to inspect.
|
|
126
|
+
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
127
|
+
* @returns {Promise<{version: string, type: string, size: number}|undefined>}
|
|
128
|
+
*/
|
|
129
|
+
async lstat(linuxPath, signal) {
|
|
130
|
+
if (String(linuxPath).trim().length === 0) {
|
|
131
|
+
throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
132
|
+
}
|
|
133
|
+
const info = await this.fs.stat(linuxPath, { follow: false, signal });
|
|
134
|
+
return info === null ? undefined : toInfo(info);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read a whole regular UTF-8 file.
|
|
139
|
+
* @param {object} target - the resolved target.
|
|
140
|
+
* @param {AbortSignal} [signal] - aborts the read.
|
|
141
|
+
* @returns {Promise<string>} the full decoded text.
|
|
142
|
+
*/
|
|
143
|
+
async readText(target, signal) {
|
|
144
|
+
return this.fs.readWholeText(target, signal);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Stream a whole regular UTF-8 file as decoded chunks.
|
|
149
|
+
* @param {object} target - the resolved target.
|
|
150
|
+
* @param {AbortSignal} [signal] - aborts the stream.
|
|
151
|
+
* @returns {Promise<AsyncGenerator<string>>} the chunk stream.
|
|
152
|
+
*/
|
|
153
|
+
async streamText(target, signal) {
|
|
154
|
+
return this.fs.streamWholeText(target, signal);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read a whole regular file as raw bytes, bounded.
|
|
159
|
+
* @param {object} target - the resolved target.
|
|
160
|
+
* @param {AbortSignal|undefined} signal - aborts the read.
|
|
161
|
+
* @param {number} maxBytes - inclusive byte cap.
|
|
162
|
+
* @returns {Promise<Uint8Array>} the bytes.
|
|
163
|
+
*/
|
|
164
|
+
async readBytes(target, signal, maxBytes) {
|
|
165
|
+
return this.fs.readWholeBytes(target, signal, maxBytes);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read one byte window of a regular file.
|
|
170
|
+
* @param {object} target - the resolved target.
|
|
171
|
+
* @param {{offset: number, length: number}} range - the window.
|
|
172
|
+
* @param {AbortSignal} [signal] - aborts the read.
|
|
173
|
+
* @returns {Promise<Uint8Array>} the window's bytes.
|
|
174
|
+
*/
|
|
175
|
+
async readByteRange(target, range, signal) {
|
|
176
|
+
return this.fs.readByteWindow(target, range, signal);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* List one directory's direct children.
|
|
181
|
+
* @param {object} target - the resolved directory.
|
|
182
|
+
* @param {AbortSignal} [signal] - aborts between children.
|
|
183
|
+
* @returns {Promise<Array<{name: string, type: string, target: {displayPath: string, targetKey: string}, version?: string, size?: number}>>}
|
|
184
|
+
*/
|
|
185
|
+
async listDir(target, signal) {
|
|
186
|
+
return this.fs.listChildren(target, signal);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Write a whole text file with the host backend's orchestration: probe,
|
|
191
|
+
* guard, best-effort diff basis, atomic publication, outcome shape. The
|
|
192
|
+
* guard is FORWARDED — a guarded create publishes with the distro-side
|
|
193
|
+
* no-replace link (atomic), and a `replaceIfVersion` write carries its
|
|
194
|
+
* version into the op, where the agent re-verifies it one syscall before
|
|
195
|
+
* the rename and refuses with `FS_STALE_VERSION` on a mismatch.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} target - the resolved target.
|
|
198
|
+
* @param {string} content - the full new file content.
|
|
199
|
+
* @param {{kind: string, version?: string}|undefined} expected - the write intent.
|
|
200
|
+
* @param {AbortSignal} [signal] - cancels the write.
|
|
201
|
+
* @param {{mode: string, workspaceRoot: string}|undefined} [policy] - the
|
|
202
|
+
* resolved policy this write was fenced with; selects the confined
|
|
203
|
+
* resident when stage-two routing is wired.
|
|
204
|
+
* @returns {Promise<{operation: string, version: string, before: string|null, after: string}>}
|
|
205
|
+
*/
|
|
206
|
+
async writeText(target, content, expected, signal, policy) {
|
|
207
|
+
const mutationAgent = await this.mutationAgent(policy);
|
|
208
|
+
return this.lock(target.targetKey, async () => {
|
|
209
|
+
const linuxPath = this.linuxOf(target);
|
|
210
|
+
const existing = await this.fs.stat(linuxPath, { signal });
|
|
211
|
+
if (existing && existing.type !== "f") {
|
|
212
|
+
throw new FsCodedError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
213
|
+
}
|
|
214
|
+
if (expected?.kind === "replaceIfVersion") {
|
|
215
|
+
if (!existing) throw new FsCodedError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
216
|
+
if (existing.version !== expected.version) {
|
|
217
|
+
throw new FsCodedError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
218
|
+
}
|
|
219
|
+
} else if (expected?.kind === "createIfAbsent" && existing) {
|
|
220
|
+
throw new FsCodedError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
221
|
+
}
|
|
222
|
+
const before =
|
|
223
|
+
existing !== null && Buffer.byteLength(content, "utf8") < this.diffBasisMaxBytes
|
|
224
|
+
? await this.fs.readTextForDiff(target, this.diffBasisMaxBytes, signal)
|
|
225
|
+
: null;
|
|
226
|
+
await this.fs.writeFileAtomic(linuxPath, content, {
|
|
227
|
+
mode: existing?.mode,
|
|
228
|
+
createIfAbsent: expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : undefined,
|
|
229
|
+
expectedVersion: expected?.kind === "replaceIfVersion" ? expected.version : undefined,
|
|
230
|
+
signal,
|
|
231
|
+
agent: mutationAgent,
|
|
232
|
+
});
|
|
233
|
+
const after = await this.fs.stat(linuxPath, { signal });
|
|
234
|
+
return {
|
|
235
|
+
operation: existing ? "update" : "create",
|
|
236
|
+
version: after ? after.version : `missing:${target.targetKey}`,
|
|
237
|
+
before,
|
|
238
|
+
after: normalizeLineEndings(content),
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Edit a text file with the host backend's orchestration: probe, version
|
|
245
|
+
* guard, LF-normalized read, literal replacement, line-ending restore,
|
|
246
|
+
* atomic publication, outcome shape. The version the edit was based on
|
|
247
|
+
* rides the write op and is re-verified distro-side one syscall before the
|
|
248
|
+
* rename, so a concurrent writer wins instead of being clobbered.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} target - the resolved target.
|
|
251
|
+
* @param {{oldString: string, newString: string, replaceAll?: boolean}} edit - the literal request.
|
|
252
|
+
* @param {{version: string}|undefined} expected - the version guard.
|
|
253
|
+
* @param {AbortSignal} [signal] - cancels the edit.
|
|
254
|
+
* @param {{mode: string, workspaceRoot: string}|undefined} [policy] - the
|
|
255
|
+
* resolved policy this edit was fenced with; selects the confined
|
|
256
|
+
* resident when stage-two routing is wired.
|
|
257
|
+
* @returns {Promise<{version: string, before: string, after: string}>}
|
|
258
|
+
*/
|
|
259
|
+
async editText(target, edit, expected, signal, policy) {
|
|
260
|
+
const mutationAgent = await this.mutationAgent(policy);
|
|
261
|
+
return this.lock(target.targetKey, async () => {
|
|
262
|
+
const linuxPath = this.linuxOf(target);
|
|
263
|
+
const existing = await this.fs.stat(linuxPath, { signal });
|
|
264
|
+
if (!existing) throw new FsCodedError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
265
|
+
if (existing.type !== "f") {
|
|
266
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
267
|
+
}
|
|
268
|
+
if (expected && existing.version !== expected.version) {
|
|
269
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
270
|
+
}
|
|
271
|
+
const original = await this.fs.readForEdit(target, signal);
|
|
272
|
+
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath);
|
|
273
|
+
const content = restoreLineEndings(edited.content, original.lineEndings);
|
|
274
|
+
await this.fs.writeFileAtomic(linuxPath, content, { mode: existing.mode, expectedVersion: existing.version, signal, agent: mutationAgent });
|
|
275
|
+
const after = await this.fs.stat(linuxPath, { signal });
|
|
276
|
+
return {
|
|
277
|
+
version: after ? after.version : `missing:${target.targetKey}`,
|
|
278
|
+
before: original.content,
|
|
279
|
+
after: edited.content,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export default AgentSubstrate;
|