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.
- dex_agents_cli_core/__init__.py +101 -0
- dex_agents_cli_core/chat.py +538 -0
- dex_agents_cli_core/cli.py +671 -0
- dex_agents_cli_core/config.py +650 -0
- dex_agents_cli_core/config_file.py +210 -0
- dex_agents_cli_core/connectors.py +531 -0
- dex_agents_cli_core/dex_server.py +345 -0
- dex_agents_cli_core/dexcli_install.py +314 -0
- dex_agents_cli_core/environment.py +136 -0
- dex_agents_cli_core/harness.py +445 -0
- dex_agents_cli_core/local_tools/__init__.py +26 -0
- dex_agents_cli_core/local_tools/browser.py +235 -0
- dex_agents_cli_core/local_tools/files.py +512 -0
- dex_agents_cli_core/local_tools/shell.py +85 -0
- dex_agents_cli_core/local_tools/source.py +458 -0
- dex_agents_cli_core/local_tools/web.py +98 -0
- dex_agents_cli_core/py.typed +0 -0
- dex_agents_cli_core/schemas/__init__.py +9 -0
- dex_agents_cli_core/schemas/config.schema.json +315 -0
- dex_agents_cli_core/schemas/session-settings.schema.json +153 -0
- dex_agents_cli_core/tool_policy.py +179 -0
- dex_agents_cli_core/turns.py +235 -0
- dex_agents_cli_core-0.5.0.dist-info/METADATA +479 -0
- dex_agents_cli_core-0.5.0.dist-info/RECORD +26 -0
- dex_agents_cli_core-0.5.0.dist-info/WHEEL +4 -0
- dex_agents_cli_core-0.5.0.dist-info/licenses/LICENSE +105 -0
|
@@ -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)"
|