deepagents 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- deepagents/backends/composite.py +321 -9
- deepagents/backends/filesystem.py +90 -23
- deepagents/backends/protocol.py +289 -27
- deepagents/backends/sandbox.py +24 -5
- deepagents/backends/state.py +6 -10
- deepagents/backends/store.py +72 -8
- deepagents/graph.py +18 -4
- deepagents/middleware/filesystem.py +209 -27
- deepagents/middleware/subagents.py +4 -2
- {deepagents-0.2.7.dist-info → deepagents-0.3.0.dist-info}/METADATA +5 -8
- deepagents-0.3.0.dist-info/RECORD +18 -0
- deepagents-0.2.7.dist-info/RECORD +0 -18
- {deepagents-0.2.7.dist-info → deepagents-0.3.0.dist-info}/WHEEL +0 -0
- {deepagents-0.2.7.dist-info → deepagents-0.3.0.dist-info}/top_level.txt +0 -0
deepagents/backends/composite.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""CompositeBackend: Route operations to different backends based on path prefix."""
|
|
2
2
|
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
3
5
|
from deepagents.backends.protocol import (
|
|
4
6
|
BackendProtocol,
|
|
5
7
|
EditResult,
|
|
6
8
|
ExecuteResponse,
|
|
9
|
+
FileDownloadResponse,
|
|
7
10
|
FileInfo,
|
|
11
|
+
FileUploadResponse,
|
|
8
12
|
GrepMatch,
|
|
9
13
|
SandboxBackendProtocol,
|
|
10
14
|
WriteResult,
|
|
@@ -93,6 +97,43 @@ class CompositeBackend:
|
|
|
93
97
|
# Path doesn't match a route: query only default backend
|
|
94
98
|
return self.default.ls_info(path)
|
|
95
99
|
|
|
100
|
+
async def als_info(self, path: str) -> list[FileInfo]:
|
|
101
|
+
"""Async version of ls_info."""
|
|
102
|
+
# Check if path matches a specific route
|
|
103
|
+
for route_prefix, backend in self.sorted_routes:
|
|
104
|
+
if path.startswith(route_prefix.rstrip("/")):
|
|
105
|
+
# Query only the matching routed backend
|
|
106
|
+
suffix = path[len(route_prefix) :]
|
|
107
|
+
search_path = f"/{suffix}" if suffix else "/"
|
|
108
|
+
infos = await backend.als_info(search_path)
|
|
109
|
+
prefixed: list[FileInfo] = []
|
|
110
|
+
for fi in infos:
|
|
111
|
+
fi = dict(fi)
|
|
112
|
+
fi["path"] = f"{route_prefix[:-1]}{fi['path']}"
|
|
113
|
+
prefixed.append(fi)
|
|
114
|
+
return prefixed
|
|
115
|
+
|
|
116
|
+
# At root, aggregate default and all routed backends
|
|
117
|
+
if path == "/":
|
|
118
|
+
results: list[FileInfo] = []
|
|
119
|
+
results.extend(await self.default.als_info(path))
|
|
120
|
+
for route_prefix, backend in self.sorted_routes:
|
|
121
|
+
# Add the route itself as a directory (e.g., /memories/)
|
|
122
|
+
results.append(
|
|
123
|
+
{
|
|
124
|
+
"path": route_prefix,
|
|
125
|
+
"is_dir": True,
|
|
126
|
+
"size": 0,
|
|
127
|
+
"modified_at": "",
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
results.sort(key=lambda x: x.get("path", ""))
|
|
132
|
+
return results
|
|
133
|
+
|
|
134
|
+
# Path doesn't match a route: query only default backend
|
|
135
|
+
return await self.default.als_info(path)
|
|
136
|
+
|
|
96
137
|
def read(
|
|
97
138
|
self,
|
|
98
139
|
file_path: str,
|
|
@@ -102,14 +143,26 @@ class CompositeBackend:
|
|
|
102
143
|
"""Read file content, routing to appropriate backend.
|
|
103
144
|
|
|
104
145
|
Args:
|
|
105
|
-
file_path: Absolute file path
|
|
106
|
-
offset: Line offset to start reading from (0-indexed)
|
|
107
|
-
limit: Maximum number of lines to
|
|
146
|
+
file_path: Absolute file path.
|
|
147
|
+
offset: Line offset to start reading from (0-indexed).
|
|
148
|
+
limit: Maximum number of lines to read.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
108
151
|
Formatted file content with line numbers, or error message.
|
|
109
152
|
"""
|
|
110
153
|
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
111
154
|
return backend.read(stripped_key, offset=offset, limit=limit)
|
|
112
155
|
|
|
156
|
+
async def aread(
|
|
157
|
+
self,
|
|
158
|
+
file_path: str,
|
|
159
|
+
offset: int = 0,
|
|
160
|
+
limit: int = 2000,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Async version of read."""
|
|
163
|
+
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
164
|
+
return await backend.aread(stripped_key, offset=offset, limit=limit)
|
|
165
|
+
|
|
113
166
|
def grep_raw(
|
|
114
167
|
self,
|
|
115
168
|
pattern: str,
|
|
@@ -142,6 +195,39 @@ class CompositeBackend:
|
|
|
142
195
|
|
|
143
196
|
return all_matches
|
|
144
197
|
|
|
198
|
+
async def agrep_raw(
|
|
199
|
+
self,
|
|
200
|
+
pattern: str,
|
|
201
|
+
path: str | None = None,
|
|
202
|
+
glob: str | None = None,
|
|
203
|
+
) -> list[GrepMatch] | str:
|
|
204
|
+
"""Async version of grep_raw."""
|
|
205
|
+
# If path targets a specific route, search only that backend
|
|
206
|
+
for route_prefix, backend in self.sorted_routes:
|
|
207
|
+
if path is not None and path.startswith(route_prefix.rstrip("/")):
|
|
208
|
+
search_path = path[len(route_prefix) - 1 :]
|
|
209
|
+
raw = await backend.agrep_raw(pattern, search_path if search_path else "/", glob)
|
|
210
|
+
if isinstance(raw, str):
|
|
211
|
+
return raw
|
|
212
|
+
return [{**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw]
|
|
213
|
+
|
|
214
|
+
# Otherwise, search default and all routed backends and merge
|
|
215
|
+
all_matches: list[GrepMatch] = []
|
|
216
|
+
raw_default = await self.default.agrep_raw(pattern, path, glob) # type: ignore[attr-defined]
|
|
217
|
+
if isinstance(raw_default, str):
|
|
218
|
+
# This happens if error occurs
|
|
219
|
+
return raw_default
|
|
220
|
+
all_matches.extend(raw_default)
|
|
221
|
+
|
|
222
|
+
for route_prefix, backend in self.routes.items():
|
|
223
|
+
raw = await backend.agrep_raw(pattern, "/", glob)
|
|
224
|
+
if isinstance(raw, str):
|
|
225
|
+
# This happens if error occurs
|
|
226
|
+
return raw
|
|
227
|
+
all_matches.extend({**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw)
|
|
228
|
+
|
|
229
|
+
return all_matches
|
|
230
|
+
|
|
145
231
|
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
146
232
|
results: list[FileInfo] = []
|
|
147
233
|
|
|
@@ -163,6 +249,28 @@ class CompositeBackend:
|
|
|
163
249
|
results.sort(key=lambda x: x.get("path", ""))
|
|
164
250
|
return results
|
|
165
251
|
|
|
252
|
+
async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
253
|
+
"""Async version of glob_info."""
|
|
254
|
+
results: list[FileInfo] = []
|
|
255
|
+
|
|
256
|
+
# Route based on path, not pattern
|
|
257
|
+
for route_prefix, backend in self.sorted_routes:
|
|
258
|
+
if path.startswith(route_prefix.rstrip("/")):
|
|
259
|
+
search_path = path[len(route_prefix) - 1 :]
|
|
260
|
+
infos = await backend.aglob_info(pattern, search_path if search_path else "/")
|
|
261
|
+
return [{**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos]
|
|
262
|
+
|
|
263
|
+
# Path doesn't match any specific route - search default backend AND all routed backends
|
|
264
|
+
results.extend(await self.default.aglob_info(pattern, path))
|
|
265
|
+
|
|
266
|
+
for route_prefix, backend in self.routes.items():
|
|
267
|
+
infos = await backend.aglob_info(pattern, "/")
|
|
268
|
+
results.extend({**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos)
|
|
269
|
+
|
|
270
|
+
# Deterministic ordering
|
|
271
|
+
results.sort(key=lambda x: x.get("path", ""))
|
|
272
|
+
return results
|
|
273
|
+
|
|
166
274
|
def write(
|
|
167
275
|
self,
|
|
168
276
|
file_path: str,
|
|
@@ -171,8 +279,10 @@ class CompositeBackend:
|
|
|
171
279
|
"""Create a new file, routing to appropriate backend.
|
|
172
280
|
|
|
173
281
|
Args:
|
|
174
|
-
file_path: Absolute file path
|
|
175
|
-
content: File content as a
|
|
282
|
+
file_path: Absolute file path.
|
|
283
|
+
content: File content as a string.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
176
286
|
Success message or Command object, or error if file already exists.
|
|
177
287
|
"""
|
|
178
288
|
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
@@ -190,6 +300,27 @@ class CompositeBackend:
|
|
|
190
300
|
pass
|
|
191
301
|
return res
|
|
192
302
|
|
|
303
|
+
async def awrite(
|
|
304
|
+
self,
|
|
305
|
+
file_path: str,
|
|
306
|
+
content: str,
|
|
307
|
+
) -> WriteResult:
|
|
308
|
+
"""Async version of write."""
|
|
309
|
+
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
310
|
+
res = await backend.awrite(stripped_key, content)
|
|
311
|
+
# If this is a state-backed update and default has state, merge so listings reflect changes
|
|
312
|
+
if res.files_update:
|
|
313
|
+
try:
|
|
314
|
+
runtime = getattr(self.default, "runtime", None)
|
|
315
|
+
if runtime is not None:
|
|
316
|
+
state = runtime.state
|
|
317
|
+
files = state.get("files", {})
|
|
318
|
+
files.update(res.files_update)
|
|
319
|
+
state["files"] = files
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
return res
|
|
323
|
+
|
|
193
324
|
def edit(
|
|
194
325
|
self,
|
|
195
326
|
file_path: str,
|
|
@@ -200,10 +331,12 @@ class CompositeBackend:
|
|
|
200
331
|
"""Edit a file, routing to appropriate backend.
|
|
201
332
|
|
|
202
333
|
Args:
|
|
203
|
-
file_path: Absolute file path
|
|
204
|
-
old_string: String to find and replace
|
|
205
|
-
new_string: Replacement string
|
|
206
|
-
replace_all: If True, replace all
|
|
334
|
+
file_path: Absolute file path.
|
|
335
|
+
old_string: String to find and replace.
|
|
336
|
+
new_string: Replacement string.
|
|
337
|
+
replace_all: If True, replace all occurrences.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
207
340
|
Success message or Command object, or error message on failure.
|
|
208
341
|
"""
|
|
209
342
|
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
@@ -220,6 +353,28 @@ class CompositeBackend:
|
|
|
220
353
|
pass
|
|
221
354
|
return res
|
|
222
355
|
|
|
356
|
+
async def aedit(
|
|
357
|
+
self,
|
|
358
|
+
file_path: str,
|
|
359
|
+
old_string: str,
|
|
360
|
+
new_string: str,
|
|
361
|
+
replace_all: bool = False,
|
|
362
|
+
) -> EditResult:
|
|
363
|
+
"""Async version of edit."""
|
|
364
|
+
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
365
|
+
res = await backend.aedit(stripped_key, old_string, new_string, replace_all=replace_all)
|
|
366
|
+
if res.files_update:
|
|
367
|
+
try:
|
|
368
|
+
runtime = getattr(self.default, "runtime", None)
|
|
369
|
+
if runtime is not None:
|
|
370
|
+
state = runtime.state
|
|
371
|
+
files = state.get("files", {})
|
|
372
|
+
files.update(res.files_update)
|
|
373
|
+
state["files"] = files
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
return res
|
|
377
|
+
|
|
223
378
|
def execute(
|
|
224
379
|
self,
|
|
225
380
|
command: str,
|
|
@@ -247,3 +402,160 @@ class CompositeBackend:
|
|
|
247
402
|
"Default backend doesn't support command execution (SandboxBackendProtocol). "
|
|
248
403
|
"To enable execution, provide a default backend that implements SandboxBackendProtocol."
|
|
249
404
|
)
|
|
405
|
+
|
|
406
|
+
async def aexecute(
|
|
407
|
+
self,
|
|
408
|
+
command: str,
|
|
409
|
+
) -> ExecuteResponse:
|
|
410
|
+
"""Async version of execute."""
|
|
411
|
+
if isinstance(self.default, SandboxBackendProtocol):
|
|
412
|
+
return await self.default.aexecute(command)
|
|
413
|
+
|
|
414
|
+
# This shouldn't be reached if the runtime check in the execute tool works correctly,
|
|
415
|
+
# but we include it as a safety fallback.
|
|
416
|
+
raise NotImplementedError(
|
|
417
|
+
"Default backend doesn't support command execution (SandboxBackendProtocol). "
|
|
418
|
+
"To enable execution, provide a default backend that implements SandboxBackendProtocol."
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
422
|
+
"""Upload multiple files, batching by backend for efficiency.
|
|
423
|
+
|
|
424
|
+
Groups files by their target backend, calls each backend's upload_files
|
|
425
|
+
once with all files for that backend, then merges results in original order.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
files: List of (path, content) tuples to upload.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
List of FileUploadResponse objects, one per input file.
|
|
432
|
+
Response order matches input order.
|
|
433
|
+
"""
|
|
434
|
+
# Pre-allocate result list
|
|
435
|
+
results: list[FileUploadResponse | None] = [None] * len(files)
|
|
436
|
+
|
|
437
|
+
# Group files by backend, tracking original indices
|
|
438
|
+
from collections import defaultdict
|
|
439
|
+
|
|
440
|
+
backend_batches: dict[BackendProtocol, list[tuple[int, str, bytes]]] = defaultdict(list)
|
|
441
|
+
|
|
442
|
+
for idx, (path, content) in enumerate(files):
|
|
443
|
+
backend, stripped_path = self._get_backend_and_key(path)
|
|
444
|
+
backend_batches[backend].append((idx, stripped_path, content))
|
|
445
|
+
|
|
446
|
+
# Process each backend's batch
|
|
447
|
+
for backend, batch in backend_batches.items():
|
|
448
|
+
# Extract data for backend call
|
|
449
|
+
indices, stripped_paths, contents = zip(*batch, strict=False)
|
|
450
|
+
batch_files = list(zip(stripped_paths, contents, strict=False))
|
|
451
|
+
|
|
452
|
+
# Call backend once with all its files
|
|
453
|
+
batch_responses = backend.upload_files(batch_files)
|
|
454
|
+
|
|
455
|
+
# Place responses at original indices with original paths
|
|
456
|
+
for i, orig_idx in enumerate(indices):
|
|
457
|
+
results[orig_idx] = FileUploadResponse(
|
|
458
|
+
path=files[orig_idx][0], # Original path
|
|
459
|
+
error=batch_responses[i].error if i < len(batch_responses) else None,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
return results # type: ignore[return-value]
|
|
463
|
+
|
|
464
|
+
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
465
|
+
"""Async version of upload_files."""
|
|
466
|
+
# Pre-allocate result list
|
|
467
|
+
results: list[FileUploadResponse | None] = [None] * len(files)
|
|
468
|
+
|
|
469
|
+
# Group files by backend, tracking original indices
|
|
470
|
+
backend_batches: dict[BackendProtocol, list[tuple[int, str, bytes]]] = defaultdict(list)
|
|
471
|
+
|
|
472
|
+
for idx, (path, content) in enumerate(files):
|
|
473
|
+
backend, stripped_path = self._get_backend_and_key(path)
|
|
474
|
+
backend_batches[backend].append((idx, stripped_path, content))
|
|
475
|
+
|
|
476
|
+
# Process each backend's batch
|
|
477
|
+
for backend, batch in backend_batches.items():
|
|
478
|
+
# Extract data for backend call
|
|
479
|
+
indices, stripped_paths, contents = zip(*batch, strict=False)
|
|
480
|
+
batch_files = list(zip(stripped_paths, contents, strict=False))
|
|
481
|
+
|
|
482
|
+
# Call backend once with all its files
|
|
483
|
+
batch_responses = await backend.aupload_files(batch_files)
|
|
484
|
+
|
|
485
|
+
# Place responses at original indices with original paths
|
|
486
|
+
for i, orig_idx in enumerate(indices):
|
|
487
|
+
results[orig_idx] = FileUploadResponse(
|
|
488
|
+
path=files[orig_idx][0], # Original path
|
|
489
|
+
error=batch_responses[i].error if i < len(batch_responses) else None,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
return results # type: ignore[return-value]
|
|
493
|
+
|
|
494
|
+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
495
|
+
"""Download multiple files, batching by backend for efficiency.
|
|
496
|
+
|
|
497
|
+
Groups paths by their target backend, calls each backend's download_files
|
|
498
|
+
once with all paths for that backend, then merges results in original order.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
paths: List of file paths to download.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
List of FileDownloadResponse objects, one per input path.
|
|
505
|
+
Response order matches input order.
|
|
506
|
+
"""
|
|
507
|
+
# Pre-allocate result list
|
|
508
|
+
results: list[FileDownloadResponse | None] = [None] * len(paths)
|
|
509
|
+
|
|
510
|
+
backend_batches: dict[BackendProtocol, list[tuple[int, str]]] = defaultdict(list)
|
|
511
|
+
|
|
512
|
+
for idx, path in enumerate(paths):
|
|
513
|
+
backend, stripped_path = self._get_backend_and_key(path)
|
|
514
|
+
backend_batches[backend].append((idx, stripped_path))
|
|
515
|
+
|
|
516
|
+
# Process each backend's batch
|
|
517
|
+
for backend, batch in backend_batches.items():
|
|
518
|
+
# Extract data for backend call
|
|
519
|
+
indices, stripped_paths = zip(*batch, strict=False)
|
|
520
|
+
|
|
521
|
+
# Call backend once with all its paths
|
|
522
|
+
batch_responses = backend.download_files(list(stripped_paths))
|
|
523
|
+
|
|
524
|
+
# Place responses at original indices with original paths
|
|
525
|
+
for i, orig_idx in enumerate(indices):
|
|
526
|
+
results[orig_idx] = FileDownloadResponse(
|
|
527
|
+
path=paths[orig_idx], # Original path
|
|
528
|
+
content=batch_responses[i].content if i < len(batch_responses) else None,
|
|
529
|
+
error=batch_responses[i].error if i < len(batch_responses) else None,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
return results # type: ignore[return-value]
|
|
533
|
+
|
|
534
|
+
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
535
|
+
"""Async version of download_files."""
|
|
536
|
+
# Pre-allocate result list
|
|
537
|
+
results: list[FileDownloadResponse | None] = [None] * len(paths)
|
|
538
|
+
|
|
539
|
+
backend_batches: dict[BackendProtocol, list[tuple[int, str]]] = defaultdict(list)
|
|
540
|
+
|
|
541
|
+
for idx, path in enumerate(paths):
|
|
542
|
+
backend, stripped_path = self._get_backend_and_key(path)
|
|
543
|
+
backend_batches[backend].append((idx, stripped_path))
|
|
544
|
+
|
|
545
|
+
# Process each backend's batch
|
|
546
|
+
for backend, batch in backend_batches.items():
|
|
547
|
+
# Extract data for backend call
|
|
548
|
+
indices, stripped_paths = zip(*batch, strict=False)
|
|
549
|
+
|
|
550
|
+
# Call backend once with all its paths
|
|
551
|
+
batch_responses = await backend.adownload_files(list(stripped_paths))
|
|
552
|
+
|
|
553
|
+
# Place responses at original indices with original paths
|
|
554
|
+
for i, orig_idx in enumerate(indices):
|
|
555
|
+
results[orig_idx] = FileDownloadResponse(
|
|
556
|
+
path=paths[orig_idx], # Original path
|
|
557
|
+
content=batch_responses[i].content if i < len(batch_responses) else None,
|
|
558
|
+
error=batch_responses[i].error if i < len(batch_responses) else None,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
return results # type: ignore[return-value]
|
|
@@ -16,7 +16,15 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
import wcmatch.glob as wcglob
|
|
18
18
|
|
|
19
|
-
from deepagents.backends.protocol import
|
|
19
|
+
from deepagents.backends.protocol import (
|
|
20
|
+
BackendProtocol,
|
|
21
|
+
EditResult,
|
|
22
|
+
FileDownloadResponse,
|
|
23
|
+
FileInfo,
|
|
24
|
+
FileUploadResponse,
|
|
25
|
+
GrepMatch,
|
|
26
|
+
WriteResult,
|
|
27
|
+
)
|
|
20
28
|
from deepagents.backends.utils import (
|
|
21
29
|
check_empty_content,
|
|
22
30
|
format_content_with_line_numbers,
|
|
@@ -185,8 +193,6 @@ class FilesystemBackend(BackendProtocol):
|
|
|
185
193
|
results.sort(key=lambda x: x.get("path", ""))
|
|
186
194
|
return results
|
|
187
195
|
|
|
188
|
-
# Removed legacy ls() convenience to keep lean surface
|
|
189
|
-
|
|
190
196
|
def read(
|
|
191
197
|
self,
|
|
192
198
|
file_path: str,
|
|
@@ -196,9 +202,11 @@ class FilesystemBackend(BackendProtocol):
|
|
|
196
202
|
"""Read file content with line numbers.
|
|
197
203
|
|
|
198
204
|
Args:
|
|
199
|
-
file_path: Absolute or relative file path
|
|
200
|
-
offset: Line offset to start reading from (0-indexed)
|
|
201
|
-
limit: Maximum number of lines to
|
|
205
|
+
file_path: Absolute or relative file path.
|
|
206
|
+
offset: Line offset to start reading from (0-indexed).
|
|
207
|
+
limit: Maximum number of lines to read.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
202
210
|
Formatted file content with line numbers, or error message.
|
|
203
211
|
"""
|
|
204
212
|
resolved_path = self._resolve_path(file_path)
|
|
@@ -208,14 +216,9 @@ class FilesystemBackend(BackendProtocol):
|
|
|
208
216
|
|
|
209
217
|
try:
|
|
210
218
|
# Open with O_NOFOLLOW where available to avoid symlink traversal
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
content = f.read()
|
|
215
|
-
except OSError:
|
|
216
|
-
# Fallback to normal open if O_NOFOLLOW unsupported or fails
|
|
217
|
-
with open(resolved_path, encoding="utf-8") as f:
|
|
218
|
-
content = f.read()
|
|
219
|
+
fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
220
|
+
with os.fdopen(fd, "r", encoding="utf-8") as f:
|
|
221
|
+
content = f.read()
|
|
219
222
|
|
|
220
223
|
empty_msg = check_empty_content(content)
|
|
221
224
|
if empty_msg:
|
|
@@ -279,13 +282,9 @@ class FilesystemBackend(BackendProtocol):
|
|
|
279
282
|
|
|
280
283
|
try:
|
|
281
284
|
# Read securely
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
content = f.read()
|
|
286
|
-
except OSError:
|
|
287
|
-
with open(resolved_path, encoding="utf-8") as f:
|
|
288
|
-
content = f.read()
|
|
285
|
+
fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
286
|
+
with os.fdopen(fd, "r", encoding="utf-8") as f:
|
|
287
|
+
content = f.read()
|
|
289
288
|
|
|
290
289
|
result = perform_string_replacement(content, old_string, new_string, replace_all)
|
|
291
290
|
|
|
@@ -306,8 +305,6 @@ class FilesystemBackend(BackendProtocol):
|
|
|
306
305
|
except (OSError, UnicodeDecodeError, UnicodeEncodeError) as e:
|
|
307
306
|
return EditResult(error=f"Error editing file '{file_path}': {e}")
|
|
308
307
|
|
|
309
|
-
# Removed legacy grep() convenience to keep lean surface
|
|
310
|
-
|
|
311
308
|
def grep_raw(
|
|
312
309
|
self,
|
|
313
310
|
pattern: str,
|
|
@@ -481,3 +478,73 @@ class FilesystemBackend(BackendProtocol):
|
|
|
481
478
|
|
|
482
479
|
results.sort(key=lambda x: x.get("path", ""))
|
|
483
480
|
return results
|
|
481
|
+
|
|
482
|
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
483
|
+
"""Upload multiple files to the filesystem.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
files: List of (path, content) tuples where content is bytes.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
List of FileUploadResponse objects, one per input file.
|
|
490
|
+
Response order matches input order.
|
|
491
|
+
"""
|
|
492
|
+
responses: list[FileUploadResponse] = []
|
|
493
|
+
for path, content in files:
|
|
494
|
+
try:
|
|
495
|
+
resolved_path = self._resolve_path(path)
|
|
496
|
+
|
|
497
|
+
# Create parent directories if needed
|
|
498
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
|
|
500
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
501
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
502
|
+
flags |= os.O_NOFOLLOW
|
|
503
|
+
fd = os.open(resolved_path, flags, 0o644)
|
|
504
|
+
with os.fdopen(fd, "wb") as f:
|
|
505
|
+
f.write(content)
|
|
506
|
+
|
|
507
|
+
responses.append(FileUploadResponse(path=path, error=None))
|
|
508
|
+
except FileNotFoundError:
|
|
509
|
+
responses.append(FileUploadResponse(path=path, error="file_not_found"))
|
|
510
|
+
except PermissionError:
|
|
511
|
+
responses.append(FileUploadResponse(path=path, error="permission_denied"))
|
|
512
|
+
except (ValueError, OSError) as e:
|
|
513
|
+
# ValueError from _resolve_path for path traversal, OSError for other file errors
|
|
514
|
+
if isinstance(e, ValueError) or "invalid" in str(e).lower():
|
|
515
|
+
responses.append(FileUploadResponse(path=path, error="invalid_path"))
|
|
516
|
+
else:
|
|
517
|
+
# Generic error fallback
|
|
518
|
+
responses.append(FileUploadResponse(path=path, error="invalid_path"))
|
|
519
|
+
|
|
520
|
+
return responses
|
|
521
|
+
|
|
522
|
+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
523
|
+
"""Download multiple files from the filesystem.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
paths: List of file paths to download.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
List of FileDownloadResponse objects, one per input path.
|
|
530
|
+
"""
|
|
531
|
+
responses: list[FileDownloadResponse] = []
|
|
532
|
+
for path in paths:
|
|
533
|
+
try:
|
|
534
|
+
resolved_path = self._resolve_path(path)
|
|
535
|
+
# Use flags to optionally prevent symlink following if
|
|
536
|
+
# supported by the OS
|
|
537
|
+
fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
538
|
+
with os.fdopen(fd, "rb") as f:
|
|
539
|
+
content = f.read()
|
|
540
|
+
responses.append(FileDownloadResponse(path=path, content=content, error=None))
|
|
541
|
+
except FileNotFoundError:
|
|
542
|
+
responses.append(FileDownloadResponse(path=path, content=None, error="file_not_found"))
|
|
543
|
+
except PermissionError:
|
|
544
|
+
responses.append(FileDownloadResponse(path=path, content=None, error="permission_denied"))
|
|
545
|
+
except IsADirectoryError:
|
|
546
|
+
responses.append(FileDownloadResponse(path=path, content=None, error="is_directory"))
|
|
547
|
+
except ValueError:
|
|
548
|
+
responses.append(FileDownloadResponse(path=path, content=None, error="invalid_path"))
|
|
549
|
+
# Let other errors propagate
|
|
550
|
+
return responses
|