dex-agents-cli-core 0.5.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.
@@ -0,0 +1,512 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """File tools confined to the workspace.
10
+
11
+ A path's symbolic links are first resolved one hop at a time with
12
+ ``readlink`` relative to their directory's descriptor, and every hop must stay
13
+ inside the workspace. The resolved path is then opened from the workspace one
14
+ component at a time with ``O_NOFOLLOW`` opens relative to the parent
15
+ directory's descriptor, so a path swapped for a link while a tool runs cannot
16
+ lead outside. Each opened descriptor's real path is checked against the
17
+ workspace again, and a file with more than one hard link is refused, because
18
+ a hard link can name a file outside the workspace.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import errno
25
+ import hashlib
26
+ import os
27
+ import re
28
+ import secrets
29
+ import stat
30
+ import sys
31
+ from collections.abc import Iterator
32
+ from dataclasses import dataclass
33
+ from pathlib import Path, PurePosixPath
34
+
35
+ from dex_agents import ToolExecutionError
36
+
37
+ #: The most text ``read_file`` and ``search_files`` return.
38
+ MAX_FILE_TOOL_OUTPUT_CHARACTERS = 100_000
39
+ _MAX_SEARCH_MATCHES = 200
40
+ _MAX_LISTED_ENTRIES = 1000
41
+ _MAX_LINK_HOPS = 40
42
+ _MAX_READ_BYTES = 16 * 1024 * 1024
43
+ #: The largest file ``edit_file`` changes, as in the TypeScript CLI core.
44
+ MAX_EDIT_BYTES = 4 * 1024 * 1024
45
+ _DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
46
+ _READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | getattr(os, "O_NONBLOCK", 0)
47
+ _CREATE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
48
+ _LINK_ERRNOS = frozenset({errno.ELOOP, errno.ENOTDIR, errno.EMLINK})
49
+
50
+
51
+ class WorkspaceFiles:
52
+ """Read and change files, refusing every path that leaves the workspace.
53
+
54
+ ``..``, an absolute path outside the workspace, a symbolic link anywhere in
55
+ a path, and a file with other hard links are all refused.
56
+ """
57
+
58
+ def __init__(self, workspace: Path) -> None:
59
+ """Confine the tools to one directory.
60
+
61
+ Args:
62
+ workspace: The workspace directory, which must exist.
63
+ """
64
+ self._root = workspace.resolve(strict=True)
65
+ root_fd = os.open(self._root, _DIRECTORY_FLAGS)
66
+ try:
67
+ # The descriptor's own path is the canonical spelling, also on case-insensitive disks.
68
+ self._canonical_root = _descriptor_path(root_fd) or str(self._root)
69
+ finally:
70
+ os.close(root_fd)
71
+ swapped = Path(str(self._root).swapcase())
72
+ self._is_case_insensitive = swapped != self._root and swapped.exists()
73
+
74
+ def read_file(self, path: str, offset: int = 0, limit: int | None = None) -> str:
75
+ """Return a text file's lines.
76
+
77
+ Args:
78
+ path: Path relative to the workspace.
79
+ offset: Zero-based first line.
80
+ limit: Most lines to return, or ``None`` for all.
81
+
82
+ Returns:
83
+ The lines, cut to ``MAX_FILE_TOOL_OUTPUT_CHARACTERS``.
84
+ """
85
+ text = self._read_text(path)
86
+ lines = text.splitlines(keepends=True)
87
+ selected = lines[offset : None if limit is None else offset + limit]
88
+ return _truncate("".join(selected))
89
+
90
+ def write_file(self, path: str, content: str) -> str:
91
+ """Replace a file's content, creating it and its directories when needed.
92
+
93
+ The new content goes to a temporary file in the same directory, which
94
+ then replaces the name, so a hard link at the name is replaced rather
95
+ than written through. A symbolic link at the name is refused.
96
+
97
+ Args:
98
+ path: Path relative to the workspace.
99
+ content: The complete new text.
100
+
101
+ Returns:
102
+ What was written.
103
+ """
104
+ parts = self._parts(path, is_file=True, is_final_link_followed=False)
105
+ self._replace(path, parts, content)
106
+ return f"wrote {len(content.encode('utf-8'))} bytes to {'/'.join(parts)}"
107
+
108
+ def edit_file(self, path: str, old_text: str, new_text: str) -> str:
109
+ """Replace the one occurrence of a text in a file.
110
+
111
+ Right before the replacement, the name must still be the same file with
112
+ the same content as when it was read; otherwise nothing is written.
113
+
114
+ Args:
115
+ path: Path relative to the workspace.
116
+ old_text: Text that occurs exactly once.
117
+ new_text: Its replacement.
118
+
119
+ Returns:
120
+ What was changed.
121
+ """
122
+ parts = self._parts(path, is_file=True, is_final_link_followed=False)
123
+ if not old_text:
124
+ raise ToolExecutionError("old_text must not be empty")
125
+ text, read_identity = self._read_text_and_identity(path, MAX_EDIT_BYTES)
126
+ count = text.count(old_text)
127
+ if count == 0:
128
+ raise ToolExecutionError(f"old_text does not occur in {path}")
129
+ if count > 1:
130
+ raise ToolExecutionError(f"old_text occurs {count} times in {path}; it must occur once")
131
+ self._replace(path, parts, text.replace(old_text, new_text, 1), read_identity)
132
+ return f"edited {'/'.join(parts)}: replaced 1 occurrence"
133
+
134
+ def list_directory(self, path: str = ".") -> str:
135
+ """List a directory's entries, one per line; directories end in ``/``, links in ``@``.
136
+
137
+ Args:
138
+ path: Directory relative to the workspace.
139
+
140
+ Returns:
141
+ The sorted entries.
142
+ """
143
+ with self._open_directory(path, self._parts(path, is_file=False)) as directory_fd:
144
+ with os.scandir(directory_fd) as entries:
145
+ lines = sorted(_describe_entry(entry) for entry in entries)
146
+ if len(lines) > _MAX_LISTED_ENTRIES:
147
+ lines = [*lines[:_MAX_LISTED_ENTRIES], f"... {len(lines) - _MAX_LISTED_ENTRIES} more"]
148
+ return "\n".join(lines) if lines else "(empty)"
149
+
150
+ def search_files(self, pattern: str, path: str = ".") -> str:
151
+ """Find lines that match a regular expression in the files under a directory.
152
+
153
+ Symbolic links and files with other hard links are skipped.
154
+
155
+ Args:
156
+ pattern: A Python regular expression.
157
+ path: Directory relative to the workspace.
158
+
159
+ Returns:
160
+ ``<path>:<line>: <text>`` per match, at most 200.
161
+ """
162
+ try:
163
+ expression = re.compile(pattern)
164
+ except re.error as error:
165
+ raise ToolExecutionError(f"invalid pattern: {error}") from error
166
+ parts = self._parts(path, is_file=False)
167
+ matches: list[str] = []
168
+ with self._open_directory(path, parts) as start_fd:
169
+ walk = os.fwalk(".", dir_fd=start_fd, follow_symlinks=False)
170
+ for directory, directory_names, file_names, directory_fd in walk:
171
+ directory_names.sort()
172
+ for file_name in sorted(file_names):
173
+ text = self._read_searched_file(file_name, directory_fd)
174
+ if text is None:
175
+ continue
176
+ relative = PurePosixPath(*parts, directory, file_name)
177
+ for number, line in enumerate(text.splitlines(), start=1):
178
+ if expression.search(line):
179
+ matches.append(f"{relative}:{number}: {line}")
180
+ if len(matches) >= _MAX_SEARCH_MATCHES:
181
+ return _truncate("\n".join(matches))
182
+ return _truncate("\n".join(matches)) if matches else "no matches"
183
+
184
+ def _read_text(self, path: str) -> str:
185
+ return self._read_text_and_identity(path)[0]
186
+
187
+ def _read_text_and_identity(
188
+ self, path: str, limit: int = _MAX_READ_BYTES
189
+ ) -> tuple[str, _FileIdentity]:
190
+ """Read a file and return its text with the identity, size, time, and hash it had.
191
+
192
+ With a ``limit`` below the read limit, a larger file is refused rather than cut.
193
+ """
194
+ parts = self._parts(path, is_file=True)
195
+ with self._open_directory(path, parts[:-1]) as directory_fd:
196
+ try:
197
+ file_fd = os.open(parts[-1], _READ_FLAGS, dir_fd=directory_fd)
198
+ except FileNotFoundError as error:
199
+ raise ToolExecutionError(f"{path} is not a file in the workspace") from error
200
+ except OSError as error:
201
+ raise self._refusal(path, error, parts[-1], directory_fd) from error
202
+ try:
203
+ status = self._check_opened_file(path, file_fd)
204
+ data = _read_descriptor(file_fd, limit + 1)
205
+ finally:
206
+ os.close(file_fd)
207
+ if limit < _MAX_READ_BYTES and len(data) > limit:
208
+ raise ToolExecutionError(
209
+ f"{path} is larger than {limit} bytes; edit_file does not change it"
210
+ )
211
+ return data.decode("utf-8", errors="replace"), _identity_of(status, data)
212
+
213
+ def _read_searched_file(self, name: str, directory_fd: int) -> str | None:
214
+ """Read one file for ``search_files``, or ``None`` for a file it must skip."""
215
+ try:
216
+ file_fd = os.open(name, _READ_FLAGS, dir_fd=directory_fd)
217
+ except OSError:
218
+ return None
219
+ try:
220
+ try:
221
+ self._check_opened_file(name, file_fd)
222
+ except ToolExecutionError:
223
+ return None
224
+ data = _read_descriptor(file_fd)
225
+ finally:
226
+ os.close(file_fd)
227
+ try:
228
+ return data.decode("utf-8")
229
+ except UnicodeDecodeError:
230
+ return None
231
+
232
+ def _replace(
233
+ self,
234
+ path: str,
235
+ parts: list[str],
236
+ content: str,
237
+ expected: _FileIdentity | None = None,
238
+ ) -> None:
239
+ """Write a temporary file in the checked directory and rename it over the name.
240
+
241
+ Every step uses the directory's descriptor, so the rename lands in the
242
+ directory that was checked even if its path changes meanwhile.
243
+ """
244
+ with self._open_directory(path, parts[:-1], is_created=True) as directory_fd:
245
+ name = parts[-1]
246
+ with contextlib.suppress(FileNotFoundError):
247
+ existing = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
248
+ if stat.S_ISDIR(existing.st_mode):
249
+ raise ToolExecutionError(f"{path} is a directory")
250
+ if stat.S_ISLNK(existing.st_mode):
251
+ raise ToolExecutionError(
252
+ f"{path} is outside the workspace {self._root}: it is a symbolic link"
253
+ )
254
+ temporary = f".{name}.dex-{secrets.token_hex(4)}"
255
+ file_fd = os.open(temporary, _CREATE_FLAGS, 0o644, dir_fd=directory_fd)
256
+ try:
257
+ _write_descriptor(file_fd, content.encode("utf-8"))
258
+ written = os.fstat(file_fd)
259
+ if expected is not None:
260
+ self._check_unchanged(path, name, directory_fd, expected)
261
+ # The directory may have moved since it was opened; check it before renaming.
262
+ self._check_inside(path, directory_fd)
263
+ os.rename(temporary, name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd)
264
+ except BaseException:
265
+ with contextlib.suppress(OSError):
266
+ os.unlink(temporary, dir_fd=directory_fd)
267
+ raise
268
+ finally:
269
+ os.close(file_fd)
270
+ self._check_renamed(path, name, directory_fd, written)
271
+
272
+ def _check_unchanged(
273
+ self, path: str, name: str, directory_fd: int, expected: _FileIdentity
274
+ ) -> None:
275
+ conflict = f"{path} changed after edit_file read it; read the file again and retry the edit"
276
+ try:
277
+ current_fd = os.open(name, _READ_FLAGS, dir_fd=directory_fd)
278
+ except OSError as error:
279
+ raise ToolExecutionError(conflict) from error
280
+ try:
281
+ status = os.fstat(current_fd)
282
+ data = _read_descriptor(current_fd, expected.size + 1)
283
+ finally:
284
+ os.close(current_fd)
285
+ if _identity_of(status, data) != expected:
286
+ raise ToolExecutionError(conflict)
287
+
288
+ def _check_renamed(
289
+ self, path: str, name: str, directory_fd: int, written: os.stat_result
290
+ ) -> None:
291
+ """Fail loudly unless the name now holds exactly the file this call wrote."""
292
+ self._check_inside(path, directory_fd)
293
+ renamed = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
294
+ if (renamed.st_dev, renamed.st_ino) != (written.st_dev, written.st_ino):
295
+ raise ToolExecutionError(
296
+ f"{path} was replaced by another file right after it was written; check it"
297
+ )
298
+
299
+ @contextlib.contextmanager
300
+ def _open_directory(
301
+ self, path: str, parts: list[str], *, is_created: bool = False
302
+ ) -> Iterator[int]:
303
+ """Open a workspace directory one component at a time, never following a link."""
304
+ directory_fd = os.open(self._root, _DIRECTORY_FLAGS)
305
+ try:
306
+ for part in parts:
307
+ try:
308
+ next_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=directory_fd)
309
+ except FileNotFoundError as error:
310
+ if not is_created:
311
+ raise ToolExecutionError(f"{path} is not in the workspace") from error
312
+ os.mkdir(part, 0o755, dir_fd=directory_fd)
313
+ next_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=directory_fd)
314
+ except OSError as error:
315
+ raise self._refusal(path, error, part, directory_fd) from error
316
+ os.close(directory_fd)
317
+ directory_fd = next_fd
318
+ self._check_inside(path, directory_fd)
319
+ yield directory_fd
320
+ finally:
321
+ os.close(directory_fd)
322
+
323
+ def _check_opened_file(self, path: str, file_fd: int) -> os.stat_result:
324
+ status = os.fstat(file_fd)
325
+ if not stat.S_ISREG(status.st_mode):
326
+ raise ToolExecutionError(f"{path} is not a regular file")
327
+ if status.st_nlink > 1:
328
+ raise ToolExecutionError(
329
+ f"{path} has other hard links, which may be outside the workspace; it is refused"
330
+ )
331
+ self._check_inside(path, file_fd)
332
+ return status
333
+
334
+ def _check_inside(self, path: str, fd: int) -> None:
335
+ opened = _descriptor_path(fd)
336
+ if opened is None:
337
+ return
338
+ root = self._canonical_root
339
+ if opened != root and not opened.startswith(root.rstrip("/") + "/"):
340
+ raise ToolExecutionError(f"{path} is outside the workspace {self._root}")
341
+
342
+ def _parts(self, path: str, *, is_file: bool, is_final_link_followed: bool = True) -> list[str]:
343
+ """Return a path's real components below the workspace, refusing every way out.
344
+
345
+ Symbolic links are resolved one hop at a time with ``readlink``
346
+ relative to their directory's descriptor, and every hop must stay
347
+ inside the workspace. ``..`` may not climb above it. With
348
+ ``is_final_link_followed`` false, a link as the last component is left
349
+ as it is, so a writer can refuse it.
350
+ """
351
+ if "\x00" in path:
352
+ raise ToolExecutionError("the path contains a NUL character")
353
+ candidate = PurePosixPath(path)
354
+ relative = self._relative_to_root(candidate) if candidate.is_absolute() else candidate
355
+ if relative is None:
356
+ raise ToolExecutionError(f"{path} is outside the workspace {self._root}")
357
+ parts = self._resolve_links(path, list(relative.parts), is_final_link_followed)
358
+ if is_file and not parts:
359
+ raise ToolExecutionError(f"{path} is a directory")
360
+ return parts
361
+
362
+ def _resolve_links(
363
+ self, path: str, pending: list[str], is_final_link_followed: bool
364
+ ) -> list[str]:
365
+ resolved: list[str] = []
366
+ hops = 0
367
+ while pending:
368
+ part = pending.pop(0)
369
+ if part in ("", "."):
370
+ continue
371
+ if part == "..":
372
+ if not resolved:
373
+ raise ToolExecutionError(f"{path} is outside the workspace {self._root}")
374
+ resolved.pop()
375
+ continue
376
+ with self._open_directory(path, resolved) as directory_fd:
377
+ try:
378
+ status = os.stat(part, dir_fd=directory_fd, follow_symlinks=False)
379
+ except FileNotFoundError:
380
+ return self._append_missing(path, resolved, [part, *pending])
381
+ is_last = not pending
382
+ if not stat.S_ISLNK(status.st_mode) or (is_last and not is_final_link_followed):
383
+ resolved.append(part)
384
+ continue
385
+ target = os.readlink(part, dir_fd=directory_fd)
386
+ hops += 1
387
+ if hops > _MAX_LINK_HOPS:
388
+ raise ToolExecutionError(f"{path} has too many symbolic links")
389
+ target_path = PurePosixPath(target)
390
+ if target_path.is_absolute():
391
+ inside = self._relative_to_root(target_path)
392
+ if inside is None:
393
+ raise ToolExecutionError(
394
+ f"{path} is outside the workspace {self._root}: "
395
+ "a symbolic link leads outside"
396
+ )
397
+ resolved = []
398
+ pending = [*inside.parts, *pending]
399
+ else:
400
+ pending = [*target_path.parts, *pending]
401
+ return resolved
402
+
403
+ def _append_missing(self, path: str, resolved: list[str], rest: list[str]) -> list[str]:
404
+ """Return the components of a path whose remaining components do not exist yet."""
405
+ parts = list(resolved)
406
+ for part in rest:
407
+ if part == "..":
408
+ if not parts:
409
+ raise ToolExecutionError(f"{path} is outside the workspace {self._root}")
410
+ parts.pop()
411
+ elif part not in ("", "."):
412
+ parts.append(part)
413
+ return parts
414
+
415
+ def _relative_to_root(self, absolute: PurePosixPath) -> PurePosixPath | None:
416
+ """Return an absolute path below the workspace relative to it, or ``None`` outside.
417
+
418
+ The workspace may be spelled by its real path, its canonical case on a
419
+ case-insensitive disk, or a path whose directories resolve to it.
420
+ """
421
+ roots = (self._root.as_posix(), self._canonical_root)
422
+ for root in roots:
423
+ with contextlib.suppress(ValueError):
424
+ return absolute.relative_to(root)
425
+ if self._is_case_insensitive:
426
+ text = absolute.as_posix()
427
+ for root in roots:
428
+ prefix = root.rstrip("/") + "/"
429
+ if text.lower() == root.lower():
430
+ return PurePosixPath(".")
431
+ if text.lower().startswith(prefix.lower()):
432
+ return PurePosixPath(text[len(prefix) :])
433
+ # Directories outside the workspace, such as /var on macOS, may be links to it.
434
+ parent = Path(absolute.parent).resolve()
435
+ for root_path in (self._root, Path(self._canonical_root)):
436
+ with contextlib.suppress(ValueError):
437
+ return PurePosixPath(parent.relative_to(root_path)) / absolute.name
438
+ return None
439
+
440
+ def _refusal(
441
+ self, path: str, error: OSError, name: str, directory_fd: int
442
+ ) -> ToolExecutionError:
443
+ if error.errno in _LINK_ERRNOS:
444
+ try:
445
+ is_link = stat.S_ISLNK(
446
+ os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode
447
+ )
448
+ except OSError:
449
+ is_link = False
450
+ if is_link:
451
+ return ToolExecutionError(
452
+ f"{path} is outside the workspace {self._root}: it goes through a symbolic link"
453
+ )
454
+ if error.errno == errno.ENOTDIR:
455
+ return ToolExecutionError(f"{path} goes through {name}, which is not a directory")
456
+ return ToolExecutionError(f"cannot open {path}: {error.strerror}")
457
+
458
+
459
+ @dataclass(frozen=True)
460
+ class _FileIdentity:
461
+ device: int
462
+ inode: int
463
+ size: int
464
+ modified_ns: int
465
+ content_sha256: str
466
+
467
+
468
+ def _identity_of(status: os.stat_result, data: bytes) -> _FileIdentity:
469
+ return _FileIdentity(
470
+ device=status.st_dev,
471
+ inode=status.st_ino,
472
+ size=status.st_size,
473
+ modified_ns=status.st_mtime_ns,
474
+ content_sha256=hashlib.sha256(data).hexdigest(),
475
+ )
476
+
477
+
478
+ def _read_descriptor(file_fd: int, limit: int = _MAX_READ_BYTES) -> bytes:
479
+ with os.fdopen(file_fd, "rb", closefd=False) as file:
480
+ return file.read(limit)
481
+
482
+
483
+ def _write_descriptor(file_fd: int, data: bytes) -> None:
484
+ view = memoryview(data)
485
+ while view:
486
+ written = os.write(file_fd, view)
487
+ view = view[written:]
488
+
489
+
490
+ def _describe_entry(entry: os.DirEntry[str]) -> str:
491
+ if entry.is_symlink():
492
+ return f"{entry.name}@"
493
+ return entry.name + ("/" if entry.is_dir(follow_symlinks=False) else "")
494
+
495
+
496
+ def _descriptor_path(fd: int) -> str | None:
497
+ """Return the real path the kernel reports for an open descriptor, or ``None``."""
498
+ if sys.platform == "darwin":
499
+ import fcntl
500
+
501
+ path = fcntl.fcntl(fd, fcntl.F_GETPATH, bytes(1024))
502
+ return os.fsdecode(path.split(b"\x00", 1)[0])
503
+ if sys.platform.startswith("linux"):
504
+ return os.readlink(f"/proc/self/fd/{fd}")
505
+ return None
506
+
507
+
508
+ def _truncate(text: str) -> str:
509
+ if len(text) <= MAX_FILE_TOOL_OUTPUT_CHARACTERS:
510
+ return text
511
+ omitted = len(text) - MAX_FILE_TOOL_OUTPUT_CHARACTERS
512
+ return f"{text[:MAX_FILE_TOOL_OUTPUT_CHARACTERS]}\n... ({omitted} more characters)"
@@ -0,0 +1,85 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """The shell tool: one command in the workspace."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import os
16
+ import signal
17
+ from pathlib import Path
18
+
19
+ from dex_agents import ToolExecutionError
20
+
21
+ #: Seconds a command may run when the agent sets no timeout.
22
+ DEFAULT_SHELL_TIMEOUT_SECONDS = 120
23
+ #: The longest timeout the agent may set.
24
+ MAX_SHELL_TIMEOUT_SECONDS = 600
25
+ _MAX_OUTPUT_CHARACTERS = 50_000
26
+
27
+
28
+ async def run_shell_command(command: str, workspace: Path, timeout_seconds: int) -> str:
29
+ """Run one command with ``/bin/sh -c`` in the workspace and report its output.
30
+
31
+ The command gets its own process group, so the terminal's Ctrl-C never
32
+ reaches it, and a timeout kills the whole group.
33
+
34
+ Args:
35
+ command: The command text.
36
+ workspace: The working directory.
37
+ timeout_seconds: Seconds before the command is killed.
38
+
39
+ Returns:
40
+ The exit code, standard output, and standard error.
41
+
42
+ Raises:
43
+ ToolExecutionError: If the command times out or cannot start.
44
+ """
45
+ try:
46
+ process = await asyncio.create_subprocess_exec(
47
+ "/bin/sh",
48
+ "-c",
49
+ command,
50
+ cwd=workspace,
51
+ stdin=asyncio.subprocess.DEVNULL,
52
+ stdout=asyncio.subprocess.PIPE,
53
+ stderr=asyncio.subprocess.PIPE,
54
+ start_new_session=True,
55
+ )
56
+ except OSError as error:
57
+ raise ToolExecutionError(f"cannot start the command: {error.strerror}") from error
58
+ try:
59
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_seconds)
60
+ except (TimeoutError, asyncio.CancelledError) as error:
61
+ _kill_process_group(process.pid)
62
+ await process.wait()
63
+ if isinstance(error, asyncio.CancelledError):
64
+ raise
65
+ raise ToolExecutionError(
66
+ f"the command ran longer than {timeout_seconds} seconds"
67
+ ) from error
68
+ sections = [f"exit code {process.returncode}"]
69
+ if stdout:
70
+ sections.append("stdout:\n" + _truncate(stdout.decode("utf-8", errors="replace")))
71
+ if stderr:
72
+ sections.append("stderr:\n" + _truncate(stderr.decode("utf-8", errors="replace")))
73
+ return "\n".join(sections)
74
+
75
+
76
+ def _kill_process_group(pid: int) -> None:
77
+ # The group may be gone already when the command exits during the timeout.
78
+ with contextlib.suppress(ProcessLookupError):
79
+ os.killpg(pid, signal.SIGKILL)
80
+
81
+
82
+ def _truncate(text: str) -> str:
83
+ if len(text) <= _MAX_OUTPUT_CHARACTERS:
84
+ return text
85
+ return f"{text[:_MAX_OUTPUT_CHARACTERS]}\n... ({len(text) - _MAX_OUTPUT_CHARACTERS} more)"