@blokjs/capabilities 2.2.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/README.md +24 -0
- package/dist/WorkspaceFilesystemCapability.d.ts +35 -0
- package/dist/WorkspaceFilesystemCapability.js +871 -0
- package/dist/contracts.d.ts +184 -0
- package/dist/contracts.js +22 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +39 -0
- package/dist/graph/BoundedGraphIndexer.d.ts +44 -0
- package/dist/graph/BoundedGraphIndexer.js +157 -0
- package/dist/graph/FakeGraphProvider.d.ts +35 -0
- package/dist/graph/FakeGraphProvider.js +280 -0
- package/dist/graph/GraphCapabilityManifests.d.ts +5 -0
- package/dist/graph/GraphCapabilityManifests.js +22 -0
- package/dist/graph/GraphProviderError.d.ts +12 -0
- package/dist/graph/GraphProviderError.js +20 -0
- package/dist/graph/GraphProviderSupport.d.ts +20 -0
- package/dist/graph/GraphProviderSupport.js +95 -0
- package/dist/graph/TetrixGraphProvider.d.ts +35 -0
- package/dist/graph/TetrixGraphProvider.js +99 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/package.json +31 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, lstatSync, realpathSync, statSync, watch as watchPath } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, win32 } from "node:path";
|
|
5
|
+
import { intersectCapabilityAuthorities, parseCapabilityAuthority, } from "@blokjs/shared";
|
|
6
|
+
import { WORKSPACE_FILESYSTEM_MAX_DURATION_MS, WORKSPACE_FILESYSTEM_MAX_LINES, WORKSPACE_FILESYSTEM_MAX_LIST_FILES, WORKSPACE_FILESYSTEM_MAX_PATH_LENGTH, WORKSPACE_FILESYSTEM_MAX_QUERY_LENGTH, WORKSPACE_FILESYSTEM_MAX_READ_BYTES, WORKSPACE_FILESYSTEM_MAX_SEARCH_BYTES, WORKSPACE_FILESYSTEM_MAX_SEARCH_FILES, WORKSPACE_FILESYSTEM_MAX_SEARCH_MATCHES, WORKSPACE_FILESYSTEM_MAX_WATCH_DEBOUNCE_MS, WORKSPACE_FILESYSTEM_MAX_WATCH_EVENTS, WORKSPACE_FILESYSTEM_MAX_WRITE_BYTES, } from "./contracts.js";
|
|
7
|
+
import { WorkspaceFilesystemError } from "./errors.js";
|
|
8
|
+
const IDENTIFIER = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/;
|
|
9
|
+
const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
10
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
11
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
12
|
+
const OPERATION_CAPABILITIES = {
|
|
13
|
+
metadata: "fs.workspace.metadata",
|
|
14
|
+
list: "fs.workspace.list",
|
|
15
|
+
read: "fs.workspace.read",
|
|
16
|
+
search: "fs.workspace.search",
|
|
17
|
+
write: "fs.workspace.write",
|
|
18
|
+
patch: "fs.workspace.write",
|
|
19
|
+
watch: "fs.workspace.watch",
|
|
20
|
+
};
|
|
21
|
+
const OPERATION_EFFECTS = {
|
|
22
|
+
metadata: ["filesystem", "read"],
|
|
23
|
+
list: ["filesystem", "read"],
|
|
24
|
+
read: ["filesystem", "read"],
|
|
25
|
+
search: ["filesystem", "read"],
|
|
26
|
+
write: ["filesystem", "write"],
|
|
27
|
+
patch: ["filesystem", "write"],
|
|
28
|
+
watch: ["filesystem", "read", "streaming"],
|
|
29
|
+
};
|
|
30
|
+
const DEFAULT_LIMITS = {
|
|
31
|
+
maxReadBytes: WORKSPACE_FILESYSTEM_MAX_READ_BYTES,
|
|
32
|
+
maxWriteBytes: WORKSPACE_FILESYSTEM_MAX_WRITE_BYTES,
|
|
33
|
+
maxListFiles: WORKSPACE_FILESYSTEM_MAX_LIST_FILES,
|
|
34
|
+
maxSearchFiles: WORKSPACE_FILESYSTEM_MAX_SEARCH_FILES,
|
|
35
|
+
maxSearchMatches: WORKSPACE_FILESYSTEM_MAX_SEARCH_MATCHES,
|
|
36
|
+
maxSearchBytes: WORKSPACE_FILESYSTEM_MAX_SEARCH_BYTES,
|
|
37
|
+
maxLines: WORKSPACE_FILESYSTEM_MAX_LINES,
|
|
38
|
+
maxWatchEvents: WORKSPACE_FILESYSTEM_MAX_WATCH_EVENTS,
|
|
39
|
+
maxDurationMs: WORKSPACE_FILESYSTEM_MAX_DURATION_MS,
|
|
40
|
+
};
|
|
41
|
+
function fsCode(error) {
|
|
42
|
+
if (error !== null && typeof error === "object" && "code" in error) {
|
|
43
|
+
const code = error.code;
|
|
44
|
+
return typeof code === "string" ? code : undefined;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function isWorkspaceError(error) {
|
|
49
|
+
return error instanceof WorkspaceFilesystemError;
|
|
50
|
+
}
|
|
51
|
+
function now() {
|
|
52
|
+
return new Date().toISOString();
|
|
53
|
+
}
|
|
54
|
+
function boundedLimit(value, fallback, maximum, label) {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return fallback;
|
|
57
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
|
|
58
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SIZE_LIMIT", `${label} exceeds the hard capability bound`);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
function throwIfCancelled(signal) {
|
|
62
|
+
if (signal?.aborted)
|
|
63
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_CANCELLED");
|
|
64
|
+
}
|
|
65
|
+
function canonicalRelativePath(value) {
|
|
66
|
+
if (typeof value !== "string" || value.length === 0 || value.length > WORKSPACE_FILESYSTEM_MAX_PATH_LENGTH)
|
|
67
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_PATH");
|
|
68
|
+
if (value.includes("\0"))
|
|
69
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_PATH");
|
|
70
|
+
const portable = value.replaceAll("\\", "/");
|
|
71
|
+
if (portable.startsWith("/") || isAbsolute(value) || win32.isAbsolute(value) || /^[A-Za-z]:/.test(value))
|
|
72
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_PATH_ESCAPE");
|
|
73
|
+
if (portable.startsWith("//") || portable.startsWith("\\\\"))
|
|
74
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_PATH_ESCAPE");
|
|
75
|
+
const parts = portable.split("/");
|
|
76
|
+
if (parts.some((part) => part === ".."))
|
|
77
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_PATH_ESCAPE");
|
|
78
|
+
if (parts.some((part) => WINDOWS_DEVICE_NAME.test(part)))
|
|
79
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_PATH");
|
|
80
|
+
const normalized = parts.filter((part) => part.length > 0 && part !== ".").join("/");
|
|
81
|
+
return normalized || ".";
|
|
82
|
+
}
|
|
83
|
+
function capabilityManifest(operation, limits) {
|
|
84
|
+
return {
|
|
85
|
+
version: "1",
|
|
86
|
+
classification: "agent-compatible",
|
|
87
|
+
effects: [...new Set(OPERATION_EFFECTS[operation])].sort(),
|
|
88
|
+
capabilities: [OPERATION_CAPABILITIES[operation]],
|
|
89
|
+
secrets: [],
|
|
90
|
+
determinism: "external",
|
|
91
|
+
idempotency: operation === "write" || operation === "patch" ? "conditionally-idempotent" : "idempotent",
|
|
92
|
+
maturity: "stable",
|
|
93
|
+
resources: {
|
|
94
|
+
maxDurationMs: limits.maxDurationMs,
|
|
95
|
+
maxInputBytes: operation === "write" || operation === "patch" ? limits.maxWriteBytes : limits.maxReadBytes,
|
|
96
|
+
maxOutputBytes: operation === "search" ? limits.maxSearchBytes : limits.maxReadBytes,
|
|
97
|
+
maxConcurrency: 1,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function workspaceFilesystemManifest(operation, limits = {}) {
|
|
102
|
+
const effective = { ...DEFAULT_LIMITS, ...limits };
|
|
103
|
+
return capabilityManifest(operation, effective);
|
|
104
|
+
}
|
|
105
|
+
export function workspaceFilesystemAuthority(operation, workspaceId) {
|
|
106
|
+
return parseCapabilityAuthority({
|
|
107
|
+
effects: OPERATION_EFFECTS[operation],
|
|
108
|
+
capabilities: [OPERATION_CAPABILITIES[operation]],
|
|
109
|
+
secrets: [],
|
|
110
|
+
fragments: workspaceId === undefined ? {} : { workspace: workspaceId },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export function workspaceRelativePath(value) {
|
|
114
|
+
return canonicalRelativePath(value);
|
|
115
|
+
}
|
|
116
|
+
function mediaType(path) {
|
|
117
|
+
const extension = extname(path).toLowerCase();
|
|
118
|
+
const types = {
|
|
119
|
+
".js": "text/javascript",
|
|
120
|
+
".jsx": "text/javascript",
|
|
121
|
+
".ts": "text/typescript",
|
|
122
|
+
".tsx": "text/typescript",
|
|
123
|
+
".json": "application/json",
|
|
124
|
+
".md": "text/markdown",
|
|
125
|
+
".txt": "text/plain",
|
|
126
|
+
".css": "text/css",
|
|
127
|
+
".html": "text/html",
|
|
128
|
+
".yml": "application/yaml",
|
|
129
|
+
".yaml": "application/yaml",
|
|
130
|
+
};
|
|
131
|
+
return types[extension];
|
|
132
|
+
}
|
|
133
|
+
function artifactId(workspaceId, path) {
|
|
134
|
+
return `workspace-file-${createHash("sha256").update(`${workspaceId}\0${path}`).digest("hex")}`;
|
|
135
|
+
}
|
|
136
|
+
function asBytes(content) {
|
|
137
|
+
return typeof content === "string" ? new TextEncoder().encode(content) : new Uint8Array(content);
|
|
138
|
+
}
|
|
139
|
+
function digest(bytes) {
|
|
140
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
141
|
+
}
|
|
142
|
+
function errorForFs(error, operation, relativePath) {
|
|
143
|
+
if (isWorkspaceError(error))
|
|
144
|
+
return error;
|
|
145
|
+
const code = fsCode(error);
|
|
146
|
+
if (code === "ENOENT")
|
|
147
|
+
return new WorkspaceFilesystemError("WORKSPACE_FS_NOT_FOUND", undefined, { operation, relativePath });
|
|
148
|
+
if (code === "EACCES" || code === "EPERM")
|
|
149
|
+
return new WorkspaceFilesystemError("WORKSPACE_FS_PERMISSION_DENIED", undefined, { operation, relativePath });
|
|
150
|
+
if (code === "ELOOP")
|
|
151
|
+
return new WorkspaceFilesystemError("WORKSPACE_FS_SYMLINK_DISALLOWED");
|
|
152
|
+
return new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_TARGET", undefined, { operation, relativePath });
|
|
153
|
+
}
|
|
154
|
+
function validateRoot(input) {
|
|
155
|
+
if (!IDENTIFIER.test(input.id) || typeof input.path !== "string" || !isAbsolute(input.path))
|
|
156
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_ROOT");
|
|
157
|
+
try {
|
|
158
|
+
const path = realpathSync(input.path);
|
|
159
|
+
if (!statSync(path).isDirectory())
|
|
160
|
+
throw new Error("not a directory");
|
|
161
|
+
return Object.freeze({ id: input.id, path });
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_ROOT");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function validateLimits(limits) {
|
|
168
|
+
const value = { ...DEFAULT_LIMITS, ...limits };
|
|
169
|
+
return {
|
|
170
|
+
maxReadBytes: boundedLimit(value.maxReadBytes, DEFAULT_LIMITS.maxReadBytes, WORKSPACE_FILESYSTEM_MAX_READ_BYTES, "maxReadBytes"),
|
|
171
|
+
maxWriteBytes: boundedLimit(value.maxWriteBytes, DEFAULT_LIMITS.maxWriteBytes, WORKSPACE_FILESYSTEM_MAX_WRITE_BYTES, "maxWriteBytes"),
|
|
172
|
+
maxListFiles: boundedLimit(value.maxListFiles, DEFAULT_LIMITS.maxListFiles, WORKSPACE_FILESYSTEM_MAX_LIST_FILES, "maxListFiles"),
|
|
173
|
+
maxSearchFiles: boundedLimit(value.maxSearchFiles, DEFAULT_LIMITS.maxSearchFiles, WORKSPACE_FILESYSTEM_MAX_SEARCH_FILES, "maxSearchFiles"),
|
|
174
|
+
maxSearchMatches: boundedLimit(value.maxSearchMatches, DEFAULT_LIMITS.maxSearchMatches, WORKSPACE_FILESYSTEM_MAX_SEARCH_MATCHES, "maxSearchMatches"),
|
|
175
|
+
maxSearchBytes: boundedLimit(value.maxSearchBytes, DEFAULT_LIMITS.maxSearchBytes, WORKSPACE_FILESYSTEM_MAX_SEARCH_BYTES, "maxSearchBytes"),
|
|
176
|
+
maxLines: boundedLimit(value.maxLines, DEFAULT_LIMITS.maxLines, WORKSPACE_FILESYSTEM_MAX_LINES, "maxLines"),
|
|
177
|
+
maxWatchEvents: boundedLimit(value.maxWatchEvents, DEFAULT_LIMITS.maxWatchEvents, WORKSPACE_FILESYSTEM_MAX_WATCH_EVENTS, "maxWatchEvents"),
|
|
178
|
+
maxDurationMs: boundedLimit(value.maxDurationMs, DEFAULT_LIMITS.maxDurationMs, WORKSPACE_FILESYSTEM_MAX_DURATION_MS, "maxDurationMs"),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async function readHandle(handle, size, signal, maxBytes) {
|
|
182
|
+
if (!Number.isSafeInteger(size) || size > maxBytes)
|
|
183
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SIZE_LIMIT");
|
|
184
|
+
const output = new Uint8Array(size);
|
|
185
|
+
let offset = 0;
|
|
186
|
+
while (offset < size) {
|
|
187
|
+
throwIfCancelled(signal);
|
|
188
|
+
const length = Math.min(CHUNK_SIZE, size - offset);
|
|
189
|
+
const result = await handle.read(output, offset, length, offset);
|
|
190
|
+
if (result.bytesRead === 0)
|
|
191
|
+
break;
|
|
192
|
+
offset += result.bytesRead;
|
|
193
|
+
}
|
|
194
|
+
return output.slice(0, offset);
|
|
195
|
+
}
|
|
196
|
+
class EventQueue {
|
|
197
|
+
values = [];
|
|
198
|
+
waiters = [];
|
|
199
|
+
ended = false;
|
|
200
|
+
push(value) {
|
|
201
|
+
if (this.ended)
|
|
202
|
+
return;
|
|
203
|
+
const waiter = this.waiters.shift();
|
|
204
|
+
if (waiter)
|
|
205
|
+
waiter(value);
|
|
206
|
+
else
|
|
207
|
+
this.values.push(value);
|
|
208
|
+
}
|
|
209
|
+
end() {
|
|
210
|
+
this.ended = true;
|
|
211
|
+
for (const waiter of this.waiters.splice(0))
|
|
212
|
+
waiter(undefined);
|
|
213
|
+
}
|
|
214
|
+
next() {
|
|
215
|
+
const value = this.values.shift();
|
|
216
|
+
if (value !== undefined)
|
|
217
|
+
return Promise.resolve(value);
|
|
218
|
+
if (this.ended)
|
|
219
|
+
return Promise.resolve(undefined);
|
|
220
|
+
return new Promise((resolveValue) => this.waiters.push(resolveValue));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export class WorkspaceFilesystemCapability {
|
|
224
|
+
roots;
|
|
225
|
+
limits;
|
|
226
|
+
rootById;
|
|
227
|
+
options;
|
|
228
|
+
constructor(options) {
|
|
229
|
+
if (options.roots.length === 0)
|
|
230
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_ROOT");
|
|
231
|
+
const roots = options.roots.map(validateRoot);
|
|
232
|
+
if (new Set(roots.map((root) => root.id)).size !== roots.length)
|
|
233
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_ROOT");
|
|
234
|
+
this.roots = Object.freeze(roots);
|
|
235
|
+
this.rootById = new Map(roots.map((root) => [root.id, root]));
|
|
236
|
+
this.limits = validateLimits(options.limits);
|
|
237
|
+
this.options = options;
|
|
238
|
+
}
|
|
239
|
+
metadata(input) {
|
|
240
|
+
return this.run("metadata", input, async (target) => this.metadataInternal(target, input.signal));
|
|
241
|
+
}
|
|
242
|
+
list(input) {
|
|
243
|
+
return this.run("list", input, async (target) => {
|
|
244
|
+
const maxFiles = boundedLimit(input.maxFiles, this.limits.maxListFiles, this.limits.maxListFiles, "maxFiles");
|
|
245
|
+
const maxBytes = boundedLimit(input.maxBytes, this.limits.maxSearchBytes, this.limits.maxSearchBytes, "maxBytes");
|
|
246
|
+
const rootStat = await this.secureStat(target, input.signal);
|
|
247
|
+
if (!rootStat.isDirectory())
|
|
248
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_TARGET");
|
|
249
|
+
const entries = [];
|
|
250
|
+
let bytesScanned = 0;
|
|
251
|
+
let truncated = false;
|
|
252
|
+
const visit = async (directory) => {
|
|
253
|
+
const children = await readdir(directory, { withFileTypes: true });
|
|
254
|
+
for (const child of children) {
|
|
255
|
+
throwIfCancelled(input.signal);
|
|
256
|
+
if (entries.length >= maxFiles) {
|
|
257
|
+
truncated = true;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const absolute = join(directory, child.name);
|
|
261
|
+
const path = this.pathFromAbsolute(target.root, absolute);
|
|
262
|
+
const childTarget = this.resolveTarget(target.root.id, path, false);
|
|
263
|
+
const childInfo = await this.secureStat(childTarget, input.signal);
|
|
264
|
+
if (bytesScanned + childInfo.size > maxBytes) {
|
|
265
|
+
truncated = true;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
bytesScanned += childInfo.size;
|
|
269
|
+
const metadata = await this.metadataInternal(childTarget, input.signal);
|
|
270
|
+
entries.push(metadata);
|
|
271
|
+
if (input.recursive && metadata.kind === "directory")
|
|
272
|
+
await visit(absolute);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
await visit(target.absolutePath);
|
|
276
|
+
return { workspaceId: target.root.id, path: target.relativePath, entries, bytesScanned, truncated };
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
read(input) {
|
|
280
|
+
return this.run("read", input, async (target) => {
|
|
281
|
+
const maxBytes = boundedLimit(input.maxBytes, this.limits.maxReadBytes, this.limits.maxReadBytes, "maxBytes");
|
|
282
|
+
const maxLines = boundedLimit(input.maxLines, this.limits.maxLines, this.limits.maxLines, "maxLines");
|
|
283
|
+
const secure = await this.readSecure(target, input.signal, maxBytes);
|
|
284
|
+
const version = digest(secure.bytes);
|
|
285
|
+
const artifact = this.makeArtifact(target, secure.stats.size, version);
|
|
286
|
+
const encoding = input.encoding ?? "utf8";
|
|
287
|
+
let content;
|
|
288
|
+
if (encoding === "bytes")
|
|
289
|
+
content = secure.bytes;
|
|
290
|
+
else if (encoding === "base64")
|
|
291
|
+
content = Buffer.from(secure.bytes).toString("base64");
|
|
292
|
+
else {
|
|
293
|
+
if (secure.bytes.includes(0))
|
|
294
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_BINARY_FILE");
|
|
295
|
+
let text;
|
|
296
|
+
try {
|
|
297
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(secure.bytes);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_ENCODING");
|
|
301
|
+
}
|
|
302
|
+
content = this.selectLines(text, input.startLine, input.endLine, maxLines);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
workspaceId: target.root.id,
|
|
306
|
+
path: target.relativePath,
|
|
307
|
+
encoding,
|
|
308
|
+
bytes: secure.bytes,
|
|
309
|
+
content,
|
|
310
|
+
sizeBytes: secure.stats.size,
|
|
311
|
+
version,
|
|
312
|
+
artifact,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
search(input) {
|
|
317
|
+
return this.run("search", input, async (target) => {
|
|
318
|
+
if (input.query.length === 0 || input.query.length > WORKSPACE_FILESYSTEM_MAX_QUERY_LENGTH)
|
|
319
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_QUERY_INVALID");
|
|
320
|
+
let expression;
|
|
321
|
+
if (input.regex) {
|
|
322
|
+
try {
|
|
323
|
+
expression = new RegExp(input.query, input.caseSensitive ? "g" : "gi");
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_QUERY_INVALID");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const maxFiles = boundedLimit(input.maxFiles, this.limits.maxSearchFiles, this.limits.maxSearchFiles, "maxFiles");
|
|
330
|
+
const maxMatches = boundedLimit(input.maxMatches, this.limits.maxSearchMatches, this.limits.maxSearchMatches, "maxMatches");
|
|
331
|
+
const maxBytes = boundedLimit(input.maxBytes, this.limits.maxSearchBytes, this.limits.maxSearchBytes, "maxBytes");
|
|
332
|
+
const maxLines = boundedLimit(input.maxLines, this.limits.maxLines, this.limits.maxLines, "maxLines");
|
|
333
|
+
const matches = [];
|
|
334
|
+
let filesScanned = 0;
|
|
335
|
+
let bytesScanned = 0;
|
|
336
|
+
let truncated = false;
|
|
337
|
+
const visit = async (absolute) => {
|
|
338
|
+
throwIfCancelled(input.signal);
|
|
339
|
+
const current = this.resolveTarget(target.root.id, this.pathFromAbsolute(target.root, absolute), false);
|
|
340
|
+
const info = await this.secureStat(current, input.signal);
|
|
341
|
+
if (info.isDirectory()) {
|
|
342
|
+
for (const child of await readdir(absolute, { withFileTypes: true })) {
|
|
343
|
+
if (filesScanned >= maxFiles || matches.length >= maxMatches) {
|
|
344
|
+
truncated = true;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
await visit(join(absolute, child.name));
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!info.isFile())
|
|
352
|
+
return;
|
|
353
|
+
filesScanned += 1;
|
|
354
|
+
if (bytesScanned + info.size > maxBytes) {
|
|
355
|
+
truncated = true;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const fileTarget = this.resolveTarget(target.root.id, this.pathFromAbsolute(target.root, absolute), false);
|
|
359
|
+
const secure = await this.readSecure(fileTarget, input.signal, Math.min(maxBytes - bytesScanned, this.limits.maxReadBytes));
|
|
360
|
+
bytesScanned += secure.bytes.byteLength;
|
|
361
|
+
if (secure.bytes.includes(0))
|
|
362
|
+
return;
|
|
363
|
+
let text;
|
|
364
|
+
try {
|
|
365
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(secure.bytes);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const lines = text.split(/\r?\n/).slice(0, maxLines);
|
|
371
|
+
if (text.split(/\r?\n/).length > maxLines)
|
|
372
|
+
truncated = true;
|
|
373
|
+
const path = fileTarget.relativePath;
|
|
374
|
+
const version = digest(secure.bytes);
|
|
375
|
+
const artifact = this.makeArtifact(fileTarget, secure.stats.size, version);
|
|
376
|
+
for (let index = 0; index < lines.length; index++) {
|
|
377
|
+
throwIfCancelled(input.signal);
|
|
378
|
+
const line = lines[index];
|
|
379
|
+
if (expression) {
|
|
380
|
+
expression.lastIndex = 0;
|
|
381
|
+
let match = expression.exec(line);
|
|
382
|
+
while (match !== null) {
|
|
383
|
+
if (matches.length >= maxMatches) {
|
|
384
|
+
truncated = true;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
matches.push({ path, line: index + 1, column: match.index + 1, text: line, artifact });
|
|
388
|
+
if (match[0].length === 0)
|
|
389
|
+
expression.lastIndex += 1;
|
|
390
|
+
match = expression.exec(line);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const haystack = input.caseSensitive ? line : line.toLocaleLowerCase();
|
|
395
|
+
const needle = input.caseSensitive ? input.query : input.query.toLocaleLowerCase();
|
|
396
|
+
let offset = haystack.indexOf(needle);
|
|
397
|
+
while (offset !== -1) {
|
|
398
|
+
if (matches.length >= maxMatches) {
|
|
399
|
+
truncated = true;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
matches.push({ path, line: index + 1, column: offset + 1, text: line, artifact });
|
|
403
|
+
offset = haystack.indexOf(needle, offset + Math.max(needle.length, 1));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
await visit(target.absolutePath);
|
|
409
|
+
return { workspaceId: target.root.id, root: target.relativePath, matches, filesScanned, bytesScanned, truncated };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
write(input) {
|
|
413
|
+
return this.run("write", input, (target) => this.writeInternal(target, input, "write"), true);
|
|
414
|
+
}
|
|
415
|
+
patch(input) {
|
|
416
|
+
return this.run("patch", input, async (target) => {
|
|
417
|
+
const secure = await this.readSecure(target, input.signal, this.limits.maxReadBytes);
|
|
418
|
+
if (secure.bytes.includes(0))
|
|
419
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_BINARY_FILE");
|
|
420
|
+
let original;
|
|
421
|
+
try {
|
|
422
|
+
original = new TextDecoder("utf-8", { fatal: true }).decode(secure.bytes);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_ENCODING");
|
|
426
|
+
}
|
|
427
|
+
const content = applyPatches(original, input.patches);
|
|
428
|
+
return this.writeInternal(target, { ...input, content, expectedVersion: input.expectedVersion }, "patch");
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
async *watch(input) {
|
|
432
|
+
const target = this.resolveInput(input);
|
|
433
|
+
await this.authorize("watch", target, input.signal);
|
|
434
|
+
const debounceMs = Math.min(input.debounceMs ?? 25, WORKSPACE_FILESYSTEM_MAX_WATCH_DEBOUNCE_MS);
|
|
435
|
+
const maxEvents = boundedLimit(input.maxEvents, this.limits.maxWatchEvents, this.limits.maxWatchEvents, "maxEvents");
|
|
436
|
+
const queue = new EventQueue();
|
|
437
|
+
const watchers = [];
|
|
438
|
+
const timers = new Map();
|
|
439
|
+
let eventCount = 0;
|
|
440
|
+
let closed = false;
|
|
441
|
+
let failure;
|
|
442
|
+
const watchedDirectory = target.absolutePath;
|
|
443
|
+
const isDirectory = await this.secureStat(target, input.signal).then((value) => value.isDirectory());
|
|
444
|
+
throwIfCancelled(input.signal);
|
|
445
|
+
const parent = isDirectory ? watchedDirectory : dirname(watchedDirectory);
|
|
446
|
+
const filter = (absolute) => {
|
|
447
|
+
const candidate = resolve(absolute);
|
|
448
|
+
const rel = relative(isDirectory ? watchedDirectory : target.absolutePath, candidate);
|
|
449
|
+
if (!isDirectory)
|
|
450
|
+
return rel === "";
|
|
451
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
452
|
+
};
|
|
453
|
+
const finish = (error) => {
|
|
454
|
+
if (closed)
|
|
455
|
+
return;
|
|
456
|
+
closed = true;
|
|
457
|
+
failure = error;
|
|
458
|
+
queue.end();
|
|
459
|
+
for (const timer of timers.values())
|
|
460
|
+
clearTimeout(timer);
|
|
461
|
+
for (const watcher of watchers)
|
|
462
|
+
watcher.close();
|
|
463
|
+
};
|
|
464
|
+
const emit = (watchDirectory, eventType, filename) => {
|
|
465
|
+
if (closed || filename === null)
|
|
466
|
+
return;
|
|
467
|
+
const absolute = join(watchDirectory, filename.toString());
|
|
468
|
+
if (!filter(absolute))
|
|
469
|
+
return;
|
|
470
|
+
const path = this.pathFromAbsolute(target.root, absolute);
|
|
471
|
+
const oldTimer = timers.get(path);
|
|
472
|
+
if (oldTimer)
|
|
473
|
+
clearTimeout(oldTimer);
|
|
474
|
+
timers.set(path, setTimeout(() => {
|
|
475
|
+
timers.delete(path);
|
|
476
|
+
if (eventCount >= maxEvents) {
|
|
477
|
+
queue.push({ type: "overflow", workspaceId: target.root.id, requiresRescan: true, observedAt: now() });
|
|
478
|
+
finish();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
eventCount += 1;
|
|
482
|
+
void this.watchEvent(target.root.id, path, eventType)
|
|
483
|
+
.then((event) => queue.push(event))
|
|
484
|
+
.catch((error) => finish(errorForFs(error, "watch", path)));
|
|
485
|
+
}, debounceMs));
|
|
486
|
+
};
|
|
487
|
+
const directories = isDirectory && input.recursive && process.platform === "linux"
|
|
488
|
+
? await this.directoriesForWatch(watchedDirectory, input.signal)
|
|
489
|
+
: [parent];
|
|
490
|
+
for (const directory of directories) {
|
|
491
|
+
const watcher = watchPath(directory, { persistent: false }, (eventType, filename) => emit(directory, eventType, filename));
|
|
492
|
+
watchers.push(watcher);
|
|
493
|
+
}
|
|
494
|
+
const abort = () => finish(new WorkspaceFilesystemError("WORKSPACE_FS_CANCELLED"));
|
|
495
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
496
|
+
const timer = setTimeout(() => finish(new WorkspaceFilesystemError("WORKSPACE_FS_TIME_LIMIT")), this.duration(input.maxDurationMs));
|
|
497
|
+
try {
|
|
498
|
+
while (true) {
|
|
499
|
+
const event = await queue.next();
|
|
500
|
+
if (event === undefined)
|
|
501
|
+
break;
|
|
502
|
+
yield event;
|
|
503
|
+
if (event.type === "overflow")
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
if (failure)
|
|
507
|
+
throw failure;
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
clearTimeout(timer);
|
|
511
|
+
input.signal?.removeEventListener("abort", abort);
|
|
512
|
+
finish();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
async run(operation, input, callback, allowMissing = false) {
|
|
516
|
+
const target = this.resolveInput(input, allowMissing);
|
|
517
|
+
await this.authorize(operation, target, input.signal);
|
|
518
|
+
throwIfCancelled(input.signal);
|
|
519
|
+
const duration = this.duration(input.maxDurationMs);
|
|
520
|
+
let timeout;
|
|
521
|
+
const deadline = new Promise((_, reject) => {
|
|
522
|
+
timeout = setTimeout(() => reject(new WorkspaceFilesystemError("WORKSPACE_FS_TIME_LIMIT")), duration);
|
|
523
|
+
});
|
|
524
|
+
try {
|
|
525
|
+
return await Promise.race([callback(target), deadline]);
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
throw errorForFs(error, operation, target.relativePath);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
if (timeout)
|
|
532
|
+
clearTimeout(timeout);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
duration(requested) {
|
|
536
|
+
return boundedLimit(requested, this.limits.maxDurationMs, this.limits.maxDurationMs, "maxDurationMs");
|
|
537
|
+
}
|
|
538
|
+
resolveInput(input, allowMissing = false) {
|
|
539
|
+
return this.resolveTarget(input.workspaceId, canonicalRelativePath(input.path), allowMissing);
|
|
540
|
+
}
|
|
541
|
+
resolveTarget(workspaceId, path, allowMissing) {
|
|
542
|
+
const root = this.rootById.get(workspaceId);
|
|
543
|
+
if (!root)
|
|
544
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_ROOT");
|
|
545
|
+
const relativePath = canonicalRelativePath(path);
|
|
546
|
+
const absolutePath = resolve(root.path, ...(relativePath === "." ? [] : relativePath.split("/")));
|
|
547
|
+
const outside = relative(root.path, absolutePath);
|
|
548
|
+
if (outside === ".." || outside.startsWith(`..${resolve("/")}`) || isAbsolute(outside))
|
|
549
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_PATH_ESCAPE");
|
|
550
|
+
const parts = relativePath === "." ? [] : relativePath.split("/");
|
|
551
|
+
let current = root.path;
|
|
552
|
+
for (let index = 0; index < parts.length; index++) {
|
|
553
|
+
current = join(current, parts[index]);
|
|
554
|
+
try {
|
|
555
|
+
const info = lstatSync(current);
|
|
556
|
+
if (info.isSymbolicLink())
|
|
557
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SYMLINK_DISALLOWED");
|
|
558
|
+
if (!info.isDirectory() && index < parts.length - 1)
|
|
559
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_TARGET");
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
if (isWorkspaceError(error))
|
|
563
|
+
throw error;
|
|
564
|
+
if (fsCode(error) === "ENOENT" && allowMissing && index === parts.length - 1)
|
|
565
|
+
break;
|
|
566
|
+
throw errorForFs(error, "resolve", relativePath);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return { root, relativePath, absolutePath };
|
|
570
|
+
}
|
|
571
|
+
pathFromAbsolute(root, absolute) {
|
|
572
|
+
const value = relative(root.path, resolve(absolute));
|
|
573
|
+
if (value === "" || value === ".")
|
|
574
|
+
return ".";
|
|
575
|
+
return canonicalRelativePath(value);
|
|
576
|
+
}
|
|
577
|
+
async authorize(operation, target, signal) {
|
|
578
|
+
const policy = this.options.policy;
|
|
579
|
+
if (!policy)
|
|
580
|
+
return;
|
|
581
|
+
throwIfCancelled(signal);
|
|
582
|
+
const requested = workspaceFilesystemAuthority(operation, target.root.id);
|
|
583
|
+
const scope = policy.authority ? intersectCapabilityAuthorities(policy.authority, requested) : requested;
|
|
584
|
+
if (!this.scopeAllows(operation, scope))
|
|
585
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_POLICY_DENIED");
|
|
586
|
+
const request = {
|
|
587
|
+
requestId: randomUUID(),
|
|
588
|
+
origin: "agent",
|
|
589
|
+
principal: policy.principal,
|
|
590
|
+
session: policy.session,
|
|
591
|
+
turn: policy.turn,
|
|
592
|
+
workflow: policy.workflow,
|
|
593
|
+
step: policy.step,
|
|
594
|
+
manifest: capabilityManifest(operation, this.limits),
|
|
595
|
+
scope,
|
|
596
|
+
layers: policy.layers,
|
|
597
|
+
signal,
|
|
598
|
+
capability: "workspace-filesystem",
|
|
599
|
+
operation,
|
|
600
|
+
workspaceId: target.root.id,
|
|
601
|
+
relativePath: target.relativePath,
|
|
602
|
+
};
|
|
603
|
+
let result;
|
|
604
|
+
try {
|
|
605
|
+
result = await policy.provider.evaluate(request);
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_POLICY_INVALID");
|
|
609
|
+
}
|
|
610
|
+
if (!result?.decision || result.decision.policyVersion !== policy.policyVersion)
|
|
611
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_POLICY_INVALID");
|
|
612
|
+
if (result.scope && !this.scopeAllows(operation, result.scope))
|
|
613
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_POLICY_DENIED");
|
|
614
|
+
if (result.decision.kind !== "allow")
|
|
615
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_POLICY_DENIED");
|
|
616
|
+
}
|
|
617
|
+
scopeAllows(operation, scope) {
|
|
618
|
+
return (scope.capabilities.includes(OPERATION_CAPABILITIES[operation]) &&
|
|
619
|
+
OPERATION_EFFECTS[operation].every((effect) => scope.effects.includes(effect)));
|
|
620
|
+
}
|
|
621
|
+
async secureStat(target, signal) {
|
|
622
|
+
throwIfCancelled(signal);
|
|
623
|
+
try {
|
|
624
|
+
const info = await lstat(target.absolutePath);
|
|
625
|
+
if (info.isSymbolicLink())
|
|
626
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SYMLINK_DISALLOWED");
|
|
627
|
+
if (!info.isFile() && !info.isDirectory())
|
|
628
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SPECIAL_FILE_DISALLOWED");
|
|
629
|
+
return info;
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
throw errorForFs(error, "stat", target.relativePath);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async readSecure(target, signal, maxBytes) {
|
|
636
|
+
let handle;
|
|
637
|
+
try {
|
|
638
|
+
throwIfCancelled(signal);
|
|
639
|
+
const before = await lstat(target.absolutePath);
|
|
640
|
+
if (before.isSymbolicLink())
|
|
641
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SYMLINK_DISALLOWED");
|
|
642
|
+
if (!before.isFile()) {
|
|
643
|
+
if (before.isDirectory())
|
|
644
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_TARGET");
|
|
645
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SPECIAL_FILE_DISALLOWED");
|
|
646
|
+
}
|
|
647
|
+
if (before.nlink > 1)
|
|
648
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_HARDLINK_DISALLOWED");
|
|
649
|
+
handle = await open(target.absolutePath, constants.O_RDONLY | O_NOFOLLOW);
|
|
650
|
+
const after = await handle.stat();
|
|
651
|
+
if (!after.isFile() || after.nlink > 1 || (before.ino && after.ino && before.ino !== after.ino))
|
|
652
|
+
throw new WorkspaceFilesystemError(after.nlink > 1 ? "WORKSPACE_FS_HARDLINK_DISALLOWED" : "WORKSPACE_FS_SYMLINK_DISALLOWED");
|
|
653
|
+
const bytes = await readHandle(handle, after.size, signal, maxBytes);
|
|
654
|
+
return { stats: after, bytes };
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (handle)
|
|
658
|
+
await handle.close().catch(() => undefined);
|
|
659
|
+
throw errorForFs(error, "read", target.relativePath);
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
if (handle)
|
|
663
|
+
await handle.close().catch(() => undefined);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
async metadataInternal(target, signal) {
|
|
667
|
+
const info = await this.secureStat(target, signal);
|
|
668
|
+
const result = {
|
|
669
|
+
path: target.relativePath,
|
|
670
|
+
kind: info.isDirectory() ? "directory" : "file",
|
|
671
|
+
sizeBytes: info.size,
|
|
672
|
+
modifiedAt: new Date(info.mtimeMs).toISOString(),
|
|
673
|
+
...(mediaType(target.relativePath) ? { mediaType: mediaType(target.relativePath) } : {}),
|
|
674
|
+
};
|
|
675
|
+
if (info.isFile()) {
|
|
676
|
+
if (info.size > this.limits.maxReadBytes) {
|
|
677
|
+
const version = this.statVersion(info);
|
|
678
|
+
return { ...result, version, artifact: this.makeArtifact(target, info.size, version) };
|
|
679
|
+
}
|
|
680
|
+
const secure = await this.readSecure(target, signal, this.limits.maxReadBytes);
|
|
681
|
+
const version = digest(secure.bytes);
|
|
682
|
+
return { ...result, version, artifact: this.makeArtifact(target, info.size, version) };
|
|
683
|
+
}
|
|
684
|
+
return result;
|
|
685
|
+
}
|
|
686
|
+
statVersion(info) {
|
|
687
|
+
return `stat:${info.size}:${Math.floor(info.mtimeMs)}:${info.ino}`;
|
|
688
|
+
}
|
|
689
|
+
makeArtifact(target, sizeBytes, version) {
|
|
690
|
+
const artifact = {
|
|
691
|
+
artifact: { id: artifactId(target.root.id, target.relativePath), kind: "workspace-file" },
|
|
692
|
+
version,
|
|
693
|
+
digest: version,
|
|
694
|
+
workspaceId: target.root.id,
|
|
695
|
+
relativePath: target.relativePath,
|
|
696
|
+
sizeBytes,
|
|
697
|
+
observedAt: now(),
|
|
698
|
+
...(this.options.provenance ? { provenance: this.options.provenance } : {}),
|
|
699
|
+
};
|
|
700
|
+
return Object.freeze(artifact);
|
|
701
|
+
}
|
|
702
|
+
selectLines(text, startLine = 1, endLine, maxLines = this.limits.maxLines) {
|
|
703
|
+
if (!Number.isSafeInteger(startLine) ||
|
|
704
|
+
startLine < 1 ||
|
|
705
|
+
(endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine < startLine)))
|
|
706
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_PATH");
|
|
707
|
+
const lines = text.split(/\r?\n/);
|
|
708
|
+
const selected = lines.slice(startLine - 1, endLine);
|
|
709
|
+
if (selected.length > maxLines)
|
|
710
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_LINE_LIMIT");
|
|
711
|
+
return selected.join("\n");
|
|
712
|
+
}
|
|
713
|
+
async writeInternal(target, input, operation) {
|
|
714
|
+
throwIfCancelled(input.signal);
|
|
715
|
+
const startedAt = performance.now();
|
|
716
|
+
const bytes = asBytes(input.content);
|
|
717
|
+
const maxBytes = boundedLimit(input.maxBytes, this.limits.maxWriteBytes, this.limits.maxWriteBytes, "maxBytes");
|
|
718
|
+
if (bytes.byteLength > maxBytes)
|
|
719
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SIZE_LIMIT");
|
|
720
|
+
let existing;
|
|
721
|
+
try {
|
|
722
|
+
existing = await lstat(target.absolutePath);
|
|
723
|
+
if (existing.isSymbolicLink())
|
|
724
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_SYMLINK_DISALLOWED");
|
|
725
|
+
if (!existing.isFile())
|
|
726
|
+
throw new WorkspaceFilesystemError(existing.isDirectory() ? "WORKSPACE_FS_INVALID_TARGET" : "WORKSPACE_FS_SPECIAL_FILE_DISALLOWED");
|
|
727
|
+
if (existing.nlink > 1)
|
|
728
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_HARDLINK_DISALLOWED");
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
if (fsCode(error) !== "ENOENT" || isWorkspaceError(error))
|
|
732
|
+
throw errorForFs(error, operation, target.relativePath);
|
|
733
|
+
}
|
|
734
|
+
if (existing && input.expectedVersion === undefined)
|
|
735
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_VERSION_REQUIRED");
|
|
736
|
+
if (input.expectedVersion !== undefined) {
|
|
737
|
+
if (!existing)
|
|
738
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_VERSION_CONFLICT");
|
|
739
|
+
const currentVersion = existing.size > this.limits.maxReadBytes
|
|
740
|
+
? this.statVersion(existing)
|
|
741
|
+
: digest((await this.readSecure(target, input.signal, this.limits.maxReadBytes)).bytes);
|
|
742
|
+
if (currentVersion !== input.expectedVersion)
|
|
743
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_VERSION_CONFLICT");
|
|
744
|
+
}
|
|
745
|
+
const parent = dirname(target.absolutePath);
|
|
746
|
+
const parentTarget = this.resolveTarget(target.root.id, this.pathFromAbsolute(target.root, parent), false);
|
|
747
|
+
const parentInfo = await this.secureStat(parentTarget, input.signal);
|
|
748
|
+
if (!parentInfo.isDirectory())
|
|
749
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_INVALID_TARGET");
|
|
750
|
+
const temporary = join(parent, `.${target.absolutePath.split(/[\\/]/).at(-1) ?? "file"}.blok-${randomUUID()}.tmp`);
|
|
751
|
+
let temporaryHandle;
|
|
752
|
+
try {
|
|
753
|
+
temporaryHandle = await open(temporary, "wx");
|
|
754
|
+
await temporaryHandle.writeFile(bytes);
|
|
755
|
+
await temporaryHandle.sync();
|
|
756
|
+
await temporaryHandle.close();
|
|
757
|
+
temporaryHandle = undefined;
|
|
758
|
+
throwIfCancelled(input.signal);
|
|
759
|
+
if (performance.now() - startedAt > this.duration(input.maxDurationMs))
|
|
760
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_TIME_LIMIT");
|
|
761
|
+
if (existing) {
|
|
762
|
+
const latest = await lstat(target.absolutePath);
|
|
763
|
+
if (latest.isSymbolicLink() || !latest.isFile() || latest.nlink > 1)
|
|
764
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_VERSION_CONFLICT");
|
|
765
|
+
const latestVersion = latest.size > this.limits.maxReadBytes
|
|
766
|
+
? this.statVersion(latest)
|
|
767
|
+
: digest((await this.readSecure(target, input.signal, this.limits.maxReadBytes)).bytes);
|
|
768
|
+
if (input.expectedVersion !== undefined && latestVersion !== input.expectedVersion)
|
|
769
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_VERSION_CONFLICT");
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
try {
|
|
773
|
+
await lstat(target.absolutePath);
|
|
774
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_VERSION_CONFLICT");
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
if (isWorkspaceError(error))
|
|
778
|
+
throw error;
|
|
779
|
+
if (fsCode(error) !== "ENOENT")
|
|
780
|
+
throw errorForFs(error, operation, target.relativePath);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
await rename(temporary, target.absolutePath);
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
if (temporaryHandle)
|
|
787
|
+
await temporaryHandle.close().catch(() => undefined);
|
|
788
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
789
|
+
if (isWorkspaceError(error))
|
|
790
|
+
throw error;
|
|
791
|
+
if (fsCode(error) === "EPERM" || fsCode(error) === "EEXIST")
|
|
792
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_ATOMIC_REPLACE_UNSUPPORTED");
|
|
793
|
+
throw errorForFs(error, operation, target.relativePath);
|
|
794
|
+
}
|
|
795
|
+
const version = digest(bytes);
|
|
796
|
+
return {
|
|
797
|
+
workspaceId: target.root.id,
|
|
798
|
+
path: target.relativePath,
|
|
799
|
+
created: !existing,
|
|
800
|
+
bytesWritten: bytes.byteLength,
|
|
801
|
+
version,
|
|
802
|
+
artifact: this.makeArtifact(target, bytes.byteLength, version),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
async directoriesForWatch(directory, signal) {
|
|
806
|
+
const directories = [directory];
|
|
807
|
+
for (let index = 0; index < directories.length && directories.length < this.limits.maxListFiles; index++) {
|
|
808
|
+
throwIfCancelled(signal);
|
|
809
|
+
for (const child of await readdir(directories[index], { withFileTypes: true })) {
|
|
810
|
+
if (directories.length >= this.limits.maxListFiles)
|
|
811
|
+
break;
|
|
812
|
+
if (child.isSymbolicLink() || !child.isDirectory())
|
|
813
|
+
continue;
|
|
814
|
+
directories.push(join(directories[index], child.name));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return directories;
|
|
818
|
+
}
|
|
819
|
+
async watchEvent(workspaceId, path, eventType) {
|
|
820
|
+
const target = this.resolveTarget(workspaceId, path, true);
|
|
821
|
+
try {
|
|
822
|
+
const info = await this.secureStat(target, undefined);
|
|
823
|
+
if (info.isFile()) {
|
|
824
|
+
if (info.size > this.limits.maxReadBytes) {
|
|
825
|
+
const version = this.statVersion(info);
|
|
826
|
+
return {
|
|
827
|
+
type: eventType === "rename" ? "created" : "changed",
|
|
828
|
+
workspaceId,
|
|
829
|
+
path,
|
|
830
|
+
version,
|
|
831
|
+
artifact: this.makeArtifact(target, info.size, version),
|
|
832
|
+
requiresRescan: false,
|
|
833
|
+
observedAt: now(),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
const secure = await this.readSecure(target, undefined, this.limits.maxReadBytes);
|
|
837
|
+
const version = digest(secure.bytes);
|
|
838
|
+
return {
|
|
839
|
+
type: eventType === "rename" ? "created" : "changed",
|
|
840
|
+
workspaceId,
|
|
841
|
+
path,
|
|
842
|
+
version,
|
|
843
|
+
artifact: this.makeArtifact(target, info.size, version),
|
|
844
|
+
requiresRescan: false,
|
|
845
|
+
observedAt: now(),
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (!isWorkspaceError(error) || error.code !== "WORKSPACE_FS_NOT_FOUND")
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
853
|
+
return { type: "deleted", workspaceId, path, requiresRescan: false, observedAt: now() };
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
function applyPatches(source, patches) {
|
|
857
|
+
const ordered = [...patches].sort((left, right) => left.start - right.start);
|
|
858
|
+
let cursor = 0;
|
|
859
|
+
let output = "";
|
|
860
|
+
for (const patch of ordered) {
|
|
861
|
+
if (!Number.isSafeInteger(patch.start) ||
|
|
862
|
+
!Number.isSafeInteger(patch.end) ||
|
|
863
|
+
patch.start < cursor ||
|
|
864
|
+
patch.end < patch.start ||
|
|
865
|
+
patch.end > source.length)
|
|
866
|
+
throw new WorkspaceFilesystemError("WORKSPACE_FS_PATCH_INVALID");
|
|
867
|
+
output += source.slice(cursor, patch.start) + patch.replacement;
|
|
868
|
+
cursor = patch.end;
|
|
869
|
+
}
|
|
870
|
+
return output + source.slice(cursor);
|
|
871
|
+
}
|