dsh-plugin-wsl-env 0.2.0 → 0.4.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 +294 -0
- package/README.md +2 -0
- package/agent/wsl-agent.sh +413 -27
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +4 -1
- package/docs/LIMITATIONS.md +67 -1
- package/docs/PARITY.md +5 -4
- package/docs/PEER-PARITY.md +60 -0
- package/docs/README.zh.md +2 -0
- package/docs/UPSTREAM-FSIO-EXPORT.md +87 -0
- package/lib/agent-confined.js +87 -0
- package/lib/agent-exec.js +22 -12
- package/lib/agent-protocol.js +65 -7
- package/lib/agent.js +279 -64
- package/lib/bwrap.js +91 -0
- package/lib/fs-decisions.js +132 -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 +271 -26
- package/lib/paths.js +14 -0
- package/lib/sandbox.js +32 -27
- package/lib/shell-env.js +6 -1
- package/package.json +6 -3
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem provider's POLICY DECISIONS, extracted from
|
|
3
|
+
* `lib/index.js` so they are unit-testable in a bare checkout: which paths are
|
|
4
|
+
* refused for naming another distro, which mode refuses a mutation outright,
|
|
5
|
+
* which write guards refuse which intents, and how a substrate failure maps
|
|
6
|
+
* onto the refusal the tool layer surfaces. Pure in, descriptor out — no I/O,
|
|
7
|
+
* no peers (the provider wraps these descriptors in `FsError` at one choke
|
|
8
|
+
* point, keeping the peer import out of this module and the decisions honest).
|
|
9
|
+
*
|
|
10
|
+
* Every message here is model-facing and must stay byte-identical to what the
|
|
11
|
+
* provider produced before the extraction — the tool layer and the model match
|
|
12
|
+
* on these strings.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-wsl/fs-decisions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isAnotherDistrosUnc } from "./paths.js";
|
|
18
|
+
|
|
19
|
+
/** A refusal descriptor the provider wraps in `FsError` at one choke point. */
|
|
20
|
+
const refusal = (message, code) => ({ message, code });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The worldPath decision: a path that resolves into another WSL distro is
|
|
24
|
+
* refused while `restrictToDistro` holds — deliberately NOT a sandbox denial,
|
|
25
|
+
* because no wider permission can lift a configuration choice.
|
|
26
|
+
* @param {object} input - the decision's inputs.
|
|
27
|
+
* @param {string} input.path - the path as the caller spelled it.
|
|
28
|
+
* @param {string} input.world - the resolved world path (a UNC for distro paths).
|
|
29
|
+
* @param {string} input.distro - the pinned distro name.
|
|
30
|
+
* @param {boolean} input.restrictToDistro - the configuration fence.
|
|
31
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
32
|
+
*/
|
|
33
|
+
export function outsideDistroRefusal({ path, world, distro, restrictToDistro }) {
|
|
34
|
+
if (!restrictToDistro || !isAnotherDistrosUnc(world, distro)) return null;
|
|
35
|
+
return refusal(
|
|
36
|
+
`"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
|
|
37
|
+
`(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
|
|
38
|
+
"FS_OUTSIDE_DISTRO",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The mutationAgent decision: does THIS mutation route to the confined
|
|
44
|
+
* resident? An absent policy (caller pre-checked), a disabled sandbox, and an
|
|
45
|
+
* approved escalation all address the plain resident — which is what those
|
|
46
|
+
* mean.
|
|
47
|
+
* @param {object} input - the decision's inputs.
|
|
48
|
+
* @param {{mode: string, workspaceRoot: string}|undefined} input.policy - the
|
|
49
|
+
* resolved file-effect policy.
|
|
50
|
+
* @param {boolean} input.sandboxEnabled - the provider's `sandbox` configuration.
|
|
51
|
+
* @returns {boolean} true when the mutation belongs to the confined resident.
|
|
52
|
+
*/
|
|
53
|
+
export function isConfinedMutation({ policy, sandboxEnabled }) {
|
|
54
|
+
return Boolean(policy) && Boolean(sandboxEnabled) && policy.mode !== "danger-full-access";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The mode-level mutation decision: `read-only` refuses every write outright;
|
|
59
|
+
* `danger-full-access` is the approved escalation and refuses nothing.
|
|
60
|
+
* `workspace-write` refuses nothing HERE — its containment check needs the
|
|
61
|
+
* fresh target and runs in the provider.
|
|
62
|
+
* @param {string} mode - the resolved policy mode.
|
|
63
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
64
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
65
|
+
*/
|
|
66
|
+
export function mutationModeRefusal(mode, displayPath) {
|
|
67
|
+
if (mode !== "read-only") return null;
|
|
68
|
+
return refusal(`cannot write "${displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The containment denial for a `workspace-write` mutation whose fresh target
|
|
73
|
+
* sat under no writable root.
|
|
74
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
75
|
+
* @returns {{message: string, code: string}} the refusal descriptor.
|
|
76
|
+
*/
|
|
77
|
+
export function workspaceWriteDenial(displayPath) {
|
|
78
|
+
return refusal(`cannot write "${displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The write/edit guard: compare the intent (`expected`) with what is on disk
|
|
83
|
+
* NOW (`existing`, a stat row or `undefined`) and refuse the stale or
|
|
84
|
+
* unobserved intent. The peer's dialect, byte for byte.
|
|
85
|
+
* @param {object|undefined} expected - the write intent (`replaceIfVersion`
|
|
86
|
+
* with a `version`, or `createIfAbsent`), or `undefined` for an unguarded write.
|
|
87
|
+
* @param {{version: string}|undefined} existing - what stat found, or undefined.
|
|
88
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
89
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
90
|
+
*/
|
|
91
|
+
export function guardRefusal(expected, existing, displayPath) {
|
|
92
|
+
if (expected === undefined) return null;
|
|
93
|
+
if (expected.kind === "replaceIfVersion") {
|
|
94
|
+
if (existing === undefined) return refusal(`cannot write "${displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
95
|
+
if (existing.version !== expected.version) {
|
|
96
|
+
return refusal(`cannot write "${displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (expected.kind === "createIfAbsent" && existing !== undefined) {
|
|
101
|
+
return refusal(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The substrate failure mapping: what the tool layer should surface for an
|
|
108
|
+
* error that crossed the `ctx.fs` seam. A coded refusal keeps its code and
|
|
109
|
+
* message byte-for-byte; anything else is the substrate being UNAVAILABLE —
|
|
110
|
+
* the share backend's "distro is gone" case wearing the agent substrate's
|
|
111
|
+
* wording — and the message names the documented opt-out, so an operator hit
|
|
112
|
+
* by the fail-closed posture can reach the old behaviour without a doc trip.
|
|
113
|
+
* @param {unknown} error - what the substrate raised.
|
|
114
|
+
* @returns {{message: string, code: string, cause: unknown}} the refusal the
|
|
115
|
+
* provider wraps in `FsError`.
|
|
116
|
+
*/
|
|
117
|
+
export function substrateFailure(error) {
|
|
118
|
+
if (isCoded(error)) return { message: error.message, code: error.code, cause: error };
|
|
119
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
return {
|
|
121
|
+
message:
|
|
122
|
+
`the distro file substrate is unavailable: ${message}. ` +
|
|
123
|
+
`Set "substrate: \\"share\\"" in the provider config to work on the Windows-side share while the agent is out.`,
|
|
124
|
+
code: "FS_IO_ERROR",
|
|
125
|
+
cause: error instanceof Error ? error : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A coded refusal: our `FsCodedError` duck (a message, a string `FS_*` code). */
|
|
130
|
+
function isCoded(error) {
|
|
131
|
+
return error instanceof Error && typeof error.code === "string" && error.code.startsWith("FS_");
|
|
132
|
+
}
|
|
@@ -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;
|