deep-agent-cli 0.1.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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/sandbox.py
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from codecs import IncrementalDecoder, getincrementaldecoder
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
import re
|
|
11
|
+
import selectors
|
|
12
|
+
import shutil
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
20
|
+
from agent.cancel import emit_tool_output, get_cancel_context
|
|
21
|
+
|
|
22
|
+
from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
|
|
23
|
+
from deepagents.backends.protocol import (
|
|
24
|
+
BackendProtocol,
|
|
25
|
+
DeleteResult,
|
|
26
|
+
EditResult,
|
|
27
|
+
ExecuteResponse,
|
|
28
|
+
FileDownloadResponse,
|
|
29
|
+
FileUploadResponse,
|
|
30
|
+
GlobResult,
|
|
31
|
+
GrepResult,
|
|
32
|
+
LsResult,
|
|
33
|
+
ReadResult,
|
|
34
|
+
SandboxBackendProtocol,
|
|
35
|
+
WriteResult,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from agent.config import SAFE_INHERITED_ENV, BindMount, SandboxConfig, default_skills_dir
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
LOG = logging.getLogger(__name__)
|
|
42
|
+
SANDBOX_ROOT = "/workspace"
|
|
43
|
+
SKILLS_ROOT = "/skills"
|
|
44
|
+
FIXED_PATH = "/usr/local/bin:/usr/bin:/bin"
|
|
45
|
+
RUNTIME_PATHS = ("/usr", "/bin", "/lib", "/lib64")
|
|
46
|
+
RUNTIME_FILES = (
|
|
47
|
+
"/etc/alternatives",
|
|
48
|
+
"/etc/group",
|
|
49
|
+
"/etc/ld.so.cache",
|
|
50
|
+
"/etc/localtime",
|
|
51
|
+
"/etc/nsswitch.conf",
|
|
52
|
+
"/etc/passwd",
|
|
53
|
+
)
|
|
54
|
+
NETWORK_FILES = ("/etc/hosts", "/etc/resolv.conf", "/etc/ssl")
|
|
55
|
+
ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
56
|
+
UNSANDBOXED_WARNING = (
|
|
57
|
+
"UNSANDBOXED MODE: bubblewrap is unavailable. Agent commands will run "
|
|
58
|
+
"directly on the host with the current user's permissions."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ExecutionMode(StrEnum):
|
|
63
|
+
SANDBOXED = "SANDBOXED"
|
|
64
|
+
UNSANDBOXED = "UNSANDBOXED"
|
|
65
|
+
CUSTOM = "CUSTOM"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SandboxUnavailableError(RuntimeError):
|
|
69
|
+
"""Raised when sandboxing is unavailable and unsafe fallback was not authorized."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class LocalExecuteResponse(ExecuteResponse):
|
|
74
|
+
"""Execute result with local metadata for the UI's ToolMessage artifact."""
|
|
75
|
+
|
|
76
|
+
host_log_path: str | None = None
|
|
77
|
+
agent_log_path: str | None = None
|
|
78
|
+
log_error: str | None = None
|
|
79
|
+
termination_reason: str | None = None
|
|
80
|
+
max_output_bytes: int | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class BackendSelection:
|
|
85
|
+
backend: SandboxBackendProtocol
|
|
86
|
+
mode: ExecutionMode
|
|
87
|
+
warning: str = ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_OUTSIDE_WORKSPACE_ERROR = "Permission denied: paths must be under /workspace or /skills"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _OutsideWorkspaceBackend(BackendProtocol):
|
|
94
|
+
"""Non-persistent default route that rejects paths outside /workspace."""
|
|
95
|
+
|
|
96
|
+
def ls(self, path: str) -> LsResult:
|
|
97
|
+
return LsResult(entries=[])
|
|
98
|
+
|
|
99
|
+
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
|
|
100
|
+
return ReadResult(error=_OUTSIDE_WORKSPACE_ERROR)
|
|
101
|
+
|
|
102
|
+
def grep(
|
|
103
|
+
self,
|
|
104
|
+
pattern: str,
|
|
105
|
+
path: str | None = None,
|
|
106
|
+
glob: str | None = None,
|
|
107
|
+
*,
|
|
108
|
+
max_count: int | None = None,
|
|
109
|
+
) -> GrepResult:
|
|
110
|
+
return GrepResult(matches=[])
|
|
111
|
+
|
|
112
|
+
def glob(self, pattern: str, path: str | None = None) -> GlobResult:
|
|
113
|
+
return GlobResult(matches=[])
|
|
114
|
+
|
|
115
|
+
def write(self, file_path: str, content: str) -> WriteResult:
|
|
116
|
+
return WriteResult(error=_OUTSIDE_WORKSPACE_ERROR)
|
|
117
|
+
|
|
118
|
+
def edit(
|
|
119
|
+
self,
|
|
120
|
+
file_path: str,
|
|
121
|
+
old_string: str,
|
|
122
|
+
new_string: str,
|
|
123
|
+
replace_all: bool = False,
|
|
124
|
+
) -> EditResult:
|
|
125
|
+
return EditResult(error=_OUTSIDE_WORKSPACE_ERROR)
|
|
126
|
+
|
|
127
|
+
def delete(self, file_path: str) -> DeleteResult:
|
|
128
|
+
return DeleteResult(error=_OUTSIDE_WORKSPACE_ERROR)
|
|
129
|
+
|
|
130
|
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
131
|
+
return [FileUploadResponse(path=path, error=_OUTSIDE_WORKSPACE_ERROR) for path, _ in files]
|
|
132
|
+
|
|
133
|
+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
134
|
+
return [FileDownloadResponse(path=path, error=_OUTSIDE_WORKSPACE_ERROR) for path in paths]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _ReadOnlySkillsBackend(FilesystemBackend):
|
|
138
|
+
"""Expose application skills to file tools without write operations."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, root: Path) -> None:
|
|
141
|
+
super().__init__(root_dir=root, virtual_mode=True, max_file_size_mb=10)
|
|
142
|
+
|
|
143
|
+
def write(self, file_path: str, content: str) -> WriteResult:
|
|
144
|
+
return WriteResult(error="Permission denied: skills are read-only")
|
|
145
|
+
|
|
146
|
+
def edit(self, file_path: str, old_string: str, new_string: str,
|
|
147
|
+
replace_all: bool = False) -> EditResult:
|
|
148
|
+
return EditResult(error="Permission denied: skills are read-only")
|
|
149
|
+
|
|
150
|
+
def delete(self, file_path: str) -> DeleteResult:
|
|
151
|
+
return DeleteResult(error="Permission denied: skills are read-only")
|
|
152
|
+
|
|
153
|
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
154
|
+
return [FileUploadResponse(path=path, error="Permission denied: skills are read-only")
|
|
155
|
+
for path, _ in files]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class WorkspaceCompositeBackend(CompositeBackend, SandboxBackendProtocol):
|
|
159
|
+
"""Expose workspace and read-only skills while delegating execution."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, executor: SandboxBackendProtocol, *, skills_dir: Path | None) -> None:
|
|
162
|
+
routes: dict[str, BackendProtocol] = {f"{SANDBOX_ROOT}/": executor}
|
|
163
|
+
if skills_dir is not None:
|
|
164
|
+
routes[f"{SKILLS_ROOT}/"] = _ReadOnlySkillsBackend(skills_dir)
|
|
165
|
+
super().__init__(
|
|
166
|
+
default=_OutsideWorkspaceBackend(),
|
|
167
|
+
routes=routes,
|
|
168
|
+
artifacts_root=f"{SANDBOX_ROOT}/.deepagents",
|
|
169
|
+
)
|
|
170
|
+
self.executor = executor
|
|
171
|
+
self.skills_dir = skills_dir
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def id(self) -> str:
|
|
175
|
+
return self.executor.id
|
|
176
|
+
|
|
177
|
+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
178
|
+
return self.executor.execute(command, timeout=timeout)
|
|
179
|
+
|
|
180
|
+
async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
181
|
+
return await self.executor.aexecute(command, timeout=timeout)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def sandbox_environment(config: SandboxConfig, environ: dict[str, str] | None = None) -> dict[str, str]:
|
|
185
|
+
source = os.environ if environ is None else environ
|
|
186
|
+
result = {
|
|
187
|
+
"PATH": FIXED_PATH,
|
|
188
|
+
"HOME": "/home/agent",
|
|
189
|
+
"PWD": SANDBOX_ROOT,
|
|
190
|
+
"TMPDIR": "/tmp",
|
|
191
|
+
}
|
|
192
|
+
for name in SAFE_INHERITED_ENV:
|
|
193
|
+
if name in source:
|
|
194
|
+
result[name] = source[name]
|
|
195
|
+
for name, value in source.items():
|
|
196
|
+
if name.startswith("LC_"):
|
|
197
|
+
result[name] = value
|
|
198
|
+
for name in config.env_allowlist:
|
|
199
|
+
if name in source:
|
|
200
|
+
result[name] = source[name]
|
|
201
|
+
result.update(config.env_set)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class BubblewrapBackend(FilesystemBackend, SandboxBackendProtocol):
|
|
206
|
+
"""Filesystem backend with command execution isolated by bubblewrap."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, config: SandboxConfig, *, executable: str) -> None:
|
|
209
|
+
workspace = _validate_config(config)
|
|
210
|
+
super().__init__(root_dir=workspace, virtual_mode=True, max_file_size_mb=10)
|
|
211
|
+
self.config = replace(config, workspace=workspace)
|
|
212
|
+
self.skills_dir = _validated_skills_dir(workspace)
|
|
213
|
+
self.executable = executable
|
|
214
|
+
self._env = sandbox_environment(config)
|
|
215
|
+
self._sandbox_id = f"bwrap-{uuid.uuid4().hex[:8]}"
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def id(self) -> str:
|
|
219
|
+
return self._sandbox_id
|
|
220
|
+
|
|
221
|
+
def command_args(self, command: str, *, network: bool = False) -> list[str]:
|
|
222
|
+
if not command or not isinstance(command, str):
|
|
223
|
+
raise ValueError("command must be a non-empty string")
|
|
224
|
+
args = [
|
|
225
|
+
self.executable,
|
|
226
|
+
"--die-with-parent",
|
|
227
|
+
"--new-session",
|
|
228
|
+
"--unshare-pid",
|
|
229
|
+
"--unshare-ipc",
|
|
230
|
+
"--unshare-uts",
|
|
231
|
+
]
|
|
232
|
+
if not network:
|
|
233
|
+
args.append("--unshare-net")
|
|
234
|
+
args += [
|
|
235
|
+
"--proc", "/proc",
|
|
236
|
+
"--dev", "/dev",
|
|
237
|
+
"--tmpfs", "/tmp",
|
|
238
|
+
"--tmpfs", "/home",
|
|
239
|
+
"--dir", "/home/agent",
|
|
240
|
+
"--dir", "/etc",
|
|
241
|
+
"--bind", str(self.config.workspace), SANDBOX_ROOT,
|
|
242
|
+
]
|
|
243
|
+
for path in (*RUNTIME_PATHS, *RUNTIME_FILES):
|
|
244
|
+
if Path(path).exists():
|
|
245
|
+
args += ["--ro-bind", path, path]
|
|
246
|
+
if network:
|
|
247
|
+
for path in NETWORK_FILES:
|
|
248
|
+
if Path(path).exists():
|
|
249
|
+
args += ["--ro-bind", path, path]
|
|
250
|
+
for mount in self.config.extra_read_only_mounts:
|
|
251
|
+
args += _mount_args("--ro-bind", mount)
|
|
252
|
+
for mount in self.config.extra_read_write_mounts:
|
|
253
|
+
args += _mount_args("--bind", mount)
|
|
254
|
+
if self.skills_dir is not None:
|
|
255
|
+
args += ["--ro-bind", str(self.skills_dir), SKILLS_ROOT]
|
|
256
|
+
args += ["--clearenv"]
|
|
257
|
+
for name, value in sorted(self._env.items()):
|
|
258
|
+
args += ["--setenv", name, value]
|
|
259
|
+
args += ["--chdir", SANDBOX_ROOT, "--", "/bin/sh", "-lc", command]
|
|
260
|
+
return args
|
|
261
|
+
|
|
262
|
+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
263
|
+
if not command or not isinstance(command, str):
|
|
264
|
+
return ExecuteResponse("Error: Command must be a non-empty string.", 1, False)
|
|
265
|
+
from agent.network import get_execute_network
|
|
266
|
+
|
|
267
|
+
effective_timeout = _effective_timeout(timeout, self.config.timeout_seconds)
|
|
268
|
+
return _run_process(
|
|
269
|
+
self.command_args(command, network=get_execute_network()),
|
|
270
|
+
timeout=effective_timeout,
|
|
271
|
+
max_output_bytes=self.config.max_output_bytes,
|
|
272
|
+
workspace=self.config.workspace,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class UnsandboxedShellBackend(LocalShellBackend):
|
|
277
|
+
"""Explicitly authorized host execution with a sanitized, ephemeral environment."""
|
|
278
|
+
|
|
279
|
+
def __init__(self, config: SandboxConfig) -> None:
|
|
280
|
+
workspace = _validate_config(config)
|
|
281
|
+
super().__init__(
|
|
282
|
+
root_dir=workspace,
|
|
283
|
+
virtual_mode=True,
|
|
284
|
+
timeout=config.timeout_seconds or 120, # Inert parent default; execute() uses our optional cap.
|
|
285
|
+
max_output_bytes=config.max_output_bytes,
|
|
286
|
+
env={},
|
|
287
|
+
inherit_env=False,
|
|
288
|
+
)
|
|
289
|
+
self.config = replace(config, workspace=workspace)
|
|
290
|
+
self._base_env = sandbox_environment(config)
|
|
291
|
+
|
|
292
|
+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
293
|
+
if not command or not isinstance(command, str):
|
|
294
|
+
return ExecuteResponse("Error: Command must be a non-empty string.", 1, False)
|
|
295
|
+
effective_timeout = _effective_timeout(timeout, self.config.timeout_seconds)
|
|
296
|
+
with tempfile.TemporaryDirectory(prefix="deepagent-home-") as home_dir:
|
|
297
|
+
temp_dir = Path(home_dir) / "tmp"
|
|
298
|
+
temp_dir.mkdir()
|
|
299
|
+
env = dict(self._base_env)
|
|
300
|
+
env.update({"HOME": home_dir, "PWD": str(self.config.workspace), "TMPDIR": str(temp_dir)})
|
|
301
|
+
return _run_process(
|
|
302
|
+
command,
|
|
303
|
+
timeout=effective_timeout,
|
|
304
|
+
max_output_bytes=self.config.max_output_bytes,
|
|
305
|
+
workspace=self.config.workspace,
|
|
306
|
+
cwd=self.config.workspace,
|
|
307
|
+
env=env,
|
|
308
|
+
shell=True,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def select_backend(config: SandboxConfig, *, check: bool = True) -> BackendSelection:
|
|
313
|
+
workspace = _validate_config(config)
|
|
314
|
+
validated = replace(config, workspace=workspace)
|
|
315
|
+
executable = _resolve_executable(validated.bwrap_path)
|
|
316
|
+
reason = (
|
|
317
|
+
"Bubblewrap is required but `bwrap` was not found.\n"
|
|
318
|
+
"Ubuntu / Debian: sudo apt install bubblewrap\n"
|
|
319
|
+
"deep-agent currently supports Linux and WSL2"
|
|
320
|
+
)
|
|
321
|
+
if executable:
|
|
322
|
+
candidate = BubblewrapBackend(validated, executable=executable)
|
|
323
|
+
if not check:
|
|
324
|
+
return BackendSelection(_workspace_backend(candidate), ExecutionMode.SANDBOXED)
|
|
325
|
+
probe = candidate.execute(
|
|
326
|
+
"true", timeout=min(5, validated.timeout_seconds) if validated.timeout_seconds else 5,
|
|
327
|
+
)
|
|
328
|
+
if probe.exit_code == 0:
|
|
329
|
+
return BackendSelection(_workspace_backend(candidate), ExecutionMode.SANDBOXED)
|
|
330
|
+
reason = f"bubblewrap preflight failed: {probe.output}"
|
|
331
|
+
if not validated.allow_unsandboxed:
|
|
332
|
+
raise SandboxUnavailableError(
|
|
333
|
+
f"{reason}. Install bubblewrap or set sandbox.allow_unsandboxed: true "
|
|
334
|
+
"in ~/.deep-agent/config.yaml."
|
|
335
|
+
)
|
|
336
|
+
warning = f"{UNSANDBOXED_WARNING} Reason: {reason}"
|
|
337
|
+
warnings.warn(warning, RuntimeWarning, stacklevel=2)
|
|
338
|
+
LOG.warning(warning)
|
|
339
|
+
fallback = UnsandboxedShellBackend(validated)
|
|
340
|
+
return BackendSelection(_workspace_backend(fallback), ExecutionMode.UNSANDBOXED, warning)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _workspace_backend(backend: SandboxBackendProtocol) -> WorkspaceCompositeBackend:
|
|
344
|
+
return WorkspaceCompositeBackend(backend, skills_dir=_validated_skills_dir(backend.config.workspace))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _validated_skills_dir(workspace: Path) -> Path | None:
|
|
348
|
+
source = default_skills_dir()
|
|
349
|
+
if source.is_symlink():
|
|
350
|
+
raise ValueError(f"skills directory must not be a symlink: {source}")
|
|
351
|
+
if not source.exists():
|
|
352
|
+
return None
|
|
353
|
+
if not source.is_dir():
|
|
354
|
+
raise ValueError(f"skills directory must be a directory: {source}")
|
|
355
|
+
resolved = source.resolve()
|
|
356
|
+
if resolved.is_relative_to(workspace):
|
|
357
|
+
raise ValueError(f"skills directory must be outside workspace: {source}")
|
|
358
|
+
return resolved
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _validated_workspace(workspace: Path) -> Path:
|
|
362
|
+
resolved = workspace.expanduser().resolve()
|
|
363
|
+
if not resolved.is_dir():
|
|
364
|
+
raise ValueError(f"sandbox workspace must be an existing directory: {resolved}")
|
|
365
|
+
return resolved
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _validate_config(config: SandboxConfig) -> Path:
|
|
369
|
+
workspace = _validated_workspace(config.workspace)
|
|
370
|
+
if config.timeout_seconds is not None and config.timeout_seconds <= 0:
|
|
371
|
+
raise ValueError(f"timeout_seconds must be positive, got {config.timeout_seconds}")
|
|
372
|
+
if config.max_output_bytes <= 0:
|
|
373
|
+
raise ValueError(f"max_output_bytes must be positive, got {config.max_output_bytes}")
|
|
374
|
+
_validated_skills_dir(workspace)
|
|
375
|
+
for mount in config.extra_read_only_mounts:
|
|
376
|
+
_mount_args("--ro-bind", mount)
|
|
377
|
+
for mount in config.extra_read_write_mounts:
|
|
378
|
+
_mount_args("--bind", mount)
|
|
379
|
+
for name in (*config.env_allowlist, *config.env_set):
|
|
380
|
+
if not ENV_NAME.fullmatch(name):
|
|
381
|
+
raise ValueError(f"invalid environment variable name: {name!r}")
|
|
382
|
+
return workspace
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _resolve_executable(value: str) -> str | None:
|
|
386
|
+
if not value.strip():
|
|
387
|
+
raise ValueError("bwrap_path cannot be empty")
|
|
388
|
+
if "/" in value:
|
|
389
|
+
candidate = Path(value).expanduser().resolve()
|
|
390
|
+
return str(candidate) if candidate.is_file() and os.access(candidate, os.X_OK) else None
|
|
391
|
+
return shutil.which(value)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _mount_args(flag: str, mount: BindMount) -> list[str]:
|
|
395
|
+
source = mount.source.expanduser().resolve()
|
|
396
|
+
destination = PurePosixPath(mount.destination)
|
|
397
|
+
if not source.exists():
|
|
398
|
+
raise ValueError(f"mount source does not exist: {source}")
|
|
399
|
+
if not destination.is_absolute() or ".." in destination.parts:
|
|
400
|
+
raise ValueError(f"mount destination must be an absolute sandbox path: {mount.destination!r}")
|
|
401
|
+
protected = {PurePosixPath(SANDBOX_ROOT), PurePosixPath("/tmp"), PurePosixPath("/home/agent")}
|
|
402
|
+
if destination in protected:
|
|
403
|
+
raise ValueError(f"extra mount cannot replace protected destination: {destination}")
|
|
404
|
+
if destination == PurePosixPath(SKILLS_ROOT) or PurePosixPath(SKILLS_ROOT) in destination.parents or destination in PurePosixPath(SKILLS_ROOT).parents:
|
|
405
|
+
raise ValueError(f"extra mount conflicts with skills destination: {destination}")
|
|
406
|
+
return [flag, str(source), str(destination)]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _effective_timeout(requested: int | None, cap: int | None) -> int | None:
|
|
410
|
+
if requested is not None and requested <= 0:
|
|
411
|
+
raise ValueError(f"timeout must be positive, got {requested}")
|
|
412
|
+
if cap is None:
|
|
413
|
+
return requested
|
|
414
|
+
return min(requested, cap) if requested is not None else cap
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _create_exec_log(workspace: Path) -> tuple[int, int, str]:
|
|
418
|
+
"""Create a private log below the workspace without following directory links."""
|
|
419
|
+
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
420
|
+
directory_fd = os.open(workspace, directory_flags)
|
|
421
|
+
try:
|
|
422
|
+
for part in (".deep-agent", "logs", "exec"):
|
|
423
|
+
try:
|
|
424
|
+
os.mkdir(part, mode=0o700, dir_fd=directory_fd)
|
|
425
|
+
except FileExistsError:
|
|
426
|
+
pass
|
|
427
|
+
child_fd = os.open(part, directory_flags, dir_fd=directory_fd)
|
|
428
|
+
os.close(directory_fd)
|
|
429
|
+
directory_fd = child_fd
|
|
430
|
+
name = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:12]}.log"
|
|
431
|
+
file_fd = os.open(
|
|
432
|
+
name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
433
|
+
0o600, dir_fd=directory_fd,
|
|
434
|
+
)
|
|
435
|
+
return file_fd, directory_fd, name
|
|
436
|
+
except Exception:
|
|
437
|
+
os.close(directory_fd)
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _write_all(fd: int, data: bytes | bytearray) -> None:
|
|
442
|
+
view = memoryview(data)
|
|
443
|
+
while view:
|
|
444
|
+
written = os.write(fd, view)
|
|
445
|
+
if written <= 0:
|
|
446
|
+
raise OSError("could not write command output log")
|
|
447
|
+
view = view[written:]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _drop_partial_character_head(data: bytearray) -> None:
|
|
451
|
+
"""Drop continuation bytes a window trim stranded at the head.
|
|
452
|
+
|
|
453
|
+
``del data[:-max]`` can cut a multi-byte UTF-8 character in half; the
|
|
454
|
+
orphaned continuation bytes at the front would decode as U+FFFD in every
|
|
455
|
+
snapshot and in the final tail.
|
|
456
|
+
"""
|
|
457
|
+
head = 0
|
|
458
|
+
while head < len(data) and (data[head] & 0xC0) == 0x80:
|
|
459
|
+
head += 1
|
|
460
|
+
del data[:head]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _decode_tail_snapshot(data: bytes) -> str:
|
|
464
|
+
"""Decode a rolling tail without flashing split characters.
|
|
465
|
+
|
|
466
|
+
A non-final incremental decode keeps a trailing partial multi-byte
|
|
467
|
+
character buffered instead of replacing it with U+FFFD; the next chunk
|
|
468
|
+
completes it and the following snapshot shows it whole.
|
|
469
|
+
"""
|
|
470
|
+
return getincrementaldecoder("utf-8")(errors="replace").decode(data)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _run_process(
|
|
474
|
+
command: list[str] | str,
|
|
475
|
+
*,
|
|
476
|
+
timeout: int | None,
|
|
477
|
+
max_output_bytes: int,
|
|
478
|
+
workspace: Path | None = None,
|
|
479
|
+
cwd: Path | None = None,
|
|
480
|
+
env: dict[str, str] | None = None,
|
|
481
|
+
shell: bool = False,
|
|
482
|
+
) -> ExecuteResponse:
|
|
483
|
+
ctx = get_cancel_context()
|
|
484
|
+
tool_call_id = ctx.tool_call_id if ctx is not None else ""
|
|
485
|
+
try:
|
|
486
|
+
process = subprocess.Popen( # noqa: S603
|
|
487
|
+
command,
|
|
488
|
+
shell=shell,
|
|
489
|
+
executable="/bin/sh" if shell else None,
|
|
490
|
+
cwd=str(cwd) if cwd else None,
|
|
491
|
+
env=env,
|
|
492
|
+
stdin=subprocess.DEVNULL,
|
|
493
|
+
stdout=subprocess.PIPE,
|
|
494
|
+
stderr=subprocess.PIPE,
|
|
495
|
+
text=False,
|
|
496
|
+
start_new_session=True,
|
|
497
|
+
)
|
|
498
|
+
except Exception as exc: # noqa: BLE001
|
|
499
|
+
return LocalExecuteResponse(
|
|
500
|
+
f"Error executing command ({type(exc).__name__}): {exc}", 1, False,
|
|
501
|
+
termination_reason="spawn_error",
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if ctx is not None:
|
|
505
|
+
ctx.register_process(process)
|
|
506
|
+
|
|
507
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
508
|
+
decoder: IncrementalDecoder = getincrementaldecoder("utf-8")(errors="replace")
|
|
509
|
+
tail_bytes = bytearray()
|
|
510
|
+
total_bytes = 0
|
|
511
|
+
truncated = False
|
|
512
|
+
cancelled = False
|
|
513
|
+
timed_out = False
|
|
514
|
+
log_fd: int | None = None
|
|
515
|
+
log_directory_fd: int | None = None
|
|
516
|
+
log_name: str | None = None
|
|
517
|
+
log_error: str | None = None
|
|
518
|
+
workspace_root = (workspace or cwd or Path.cwd()).resolve()
|
|
519
|
+
post_exit_idle_since: float | None = None
|
|
520
|
+
|
|
521
|
+
assert process.stdout is not None
|
|
522
|
+
assert process.stderr is not None
|
|
523
|
+
selector = selectors.DefaultSelector()
|
|
524
|
+
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
|
|
525
|
+
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
|
|
526
|
+
|
|
527
|
+
def _discard_log(exc: OSError) -> None:
|
|
528
|
+
nonlocal log_fd, log_error
|
|
529
|
+
log_error = f"{type(exc).__name__}: {exc}"
|
|
530
|
+
if log_fd is not None:
|
|
531
|
+
try:
|
|
532
|
+
os.close(log_fd)
|
|
533
|
+
except OSError:
|
|
534
|
+
pass
|
|
535
|
+
log_fd = None
|
|
536
|
+
if log_directory_fd is not None and log_name is not None:
|
|
537
|
+
try:
|
|
538
|
+
os.unlink(log_name, dir_fd=log_directory_fd)
|
|
539
|
+
except OSError:
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
def _append_chunk(raw: bytes) -> None:
|
|
543
|
+
nonlocal total_bytes, truncated
|
|
544
|
+
nonlocal log_fd, log_directory_fd, log_name, log_error
|
|
545
|
+
if not raw:
|
|
546
|
+
return
|
|
547
|
+
remaining = max(0, max_output_bytes - total_bytes)
|
|
548
|
+
accepted = raw[:remaining] if remaining > 0 else b""
|
|
549
|
+
if accepted:
|
|
550
|
+
text = decoder.decode(accepted)
|
|
551
|
+
if text:
|
|
552
|
+
emit_tool_output(tool_call_id, text, stream="merged")
|
|
553
|
+
if not truncated and total_bytes + len(raw) > max_output_bytes:
|
|
554
|
+
truncated = True
|
|
555
|
+
if log_fd is None and log_error is None:
|
|
556
|
+
try:
|
|
557
|
+
log_fd, log_directory_fd, log_name = _create_exec_log(workspace_root)
|
|
558
|
+
_write_all(log_fd, tail_bytes)
|
|
559
|
+
_write_all(log_fd, raw)
|
|
560
|
+
except OSError as exc:
|
|
561
|
+
_discard_log(exc)
|
|
562
|
+
elif log_fd is not None:
|
|
563
|
+
try:
|
|
564
|
+
_write_all(log_fd, raw)
|
|
565
|
+
except OSError as exc:
|
|
566
|
+
_discard_log(exc)
|
|
567
|
+
total_bytes += len(raw)
|
|
568
|
+
tail_bytes.extend(raw)
|
|
569
|
+
if len(tail_bytes) > max_output_bytes:
|
|
570
|
+
del tail_bytes[:-max_output_bytes]
|
|
571
|
+
_drop_partial_character_head(tail_bytes)
|
|
572
|
+
if truncated:
|
|
573
|
+
# Replace the bounded in-memory view with the latest bytes after
|
|
574
|
+
# every chunk. The full stream continues into the workspace log.
|
|
575
|
+
current_tail = _decode_tail_snapshot(bytes(tail_bytes))
|
|
576
|
+
emit_tool_output(tool_call_id, current_tail, stream="tail_snapshot")
|
|
577
|
+
|
|
578
|
+
try:
|
|
579
|
+
while True:
|
|
580
|
+
if ctx is not None and ctx.cancelled:
|
|
581
|
+
cancelled = True
|
|
582
|
+
_kill_process_group_pid(process.pid)
|
|
583
|
+
elif deadline is not None and not cancelled and time.monotonic() >= deadline:
|
|
584
|
+
timed_out = True
|
|
585
|
+
_kill_process_group_pid(process.pid)
|
|
586
|
+
now = time.monotonic()
|
|
587
|
+
stopping = cancelled or timed_out or process.poll() is not None
|
|
588
|
+
if stopping:
|
|
589
|
+
if not selector.get_map():
|
|
590
|
+
break
|
|
591
|
+
if post_exit_idle_since is None:
|
|
592
|
+
post_exit_idle_since = now
|
|
593
|
+
if now - post_exit_idle_since >= 0.1:
|
|
594
|
+
break
|
|
595
|
+
wait = 0.1
|
|
596
|
+
if deadline is not None and not cancelled and not timed_out:
|
|
597
|
+
wait = max(0.0, min(0.1, deadline - now))
|
|
598
|
+
if post_exit_idle_since is not None:
|
|
599
|
+
wait = min(wait, max(0.0, 0.1 - (now - post_exit_idle_since)))
|
|
600
|
+
if selector.get_map():
|
|
601
|
+
events = selector.select(timeout=wait)
|
|
602
|
+
else:
|
|
603
|
+
time.sleep(wait)
|
|
604
|
+
events = []
|
|
605
|
+
if not events:
|
|
606
|
+
if stopping and not selector.get_map():
|
|
607
|
+
break
|
|
608
|
+
continue
|
|
609
|
+
for key, _mask in events:
|
|
610
|
+
try:
|
|
611
|
+
chunk = key.fileobj.read1(4096) # type: ignore[union-attr]
|
|
612
|
+
except Exception: # noqa: BLE001
|
|
613
|
+
chunk = b""
|
|
614
|
+
if chunk:
|
|
615
|
+
_append_chunk(chunk)
|
|
616
|
+
if stopping or process.poll() is not None:
|
|
617
|
+
post_exit_idle_since = time.monotonic()
|
|
618
|
+
else:
|
|
619
|
+
try:
|
|
620
|
+
selector.unregister(key.fileobj)
|
|
621
|
+
except Exception: # noqa: BLE001
|
|
622
|
+
pass
|
|
623
|
+
if (cancelled or timed_out or process.poll() is not None) and not selector.get_map():
|
|
624
|
+
break
|
|
625
|
+
# Final decoder flush for partial multi-byte sequences.
|
|
626
|
+
if not truncated:
|
|
627
|
+
tail = decoder.decode(b"", final=True)
|
|
628
|
+
if tail:
|
|
629
|
+
emit_tool_output(tool_call_id, tail, stream="merged")
|
|
630
|
+
try:
|
|
631
|
+
process.wait(timeout=1)
|
|
632
|
+
except subprocess.TimeoutExpired:
|
|
633
|
+
_kill_process_group_pid(process.pid)
|
|
634
|
+
process.wait(timeout=1)
|
|
635
|
+
finally:
|
|
636
|
+
selector.close()
|
|
637
|
+
process.stdout.close()
|
|
638
|
+
process.stderr.close()
|
|
639
|
+
if ctx is not None:
|
|
640
|
+
ctx.clear_process()
|
|
641
|
+
if log_fd is not None:
|
|
642
|
+
try:
|
|
643
|
+
os.fsync(log_fd)
|
|
644
|
+
os.close(log_fd)
|
|
645
|
+
log_fd = None
|
|
646
|
+
except OSError as exc:
|
|
647
|
+
_discard_log(exc)
|
|
648
|
+
if log_directory_fd is not None:
|
|
649
|
+
os.close(log_directory_fd)
|
|
650
|
+
|
|
651
|
+
output = bytes(tail_bytes).decode("utf-8", errors="replace") if tail_bytes else ""
|
|
652
|
+
host_log_path: str | None = None
|
|
653
|
+
agent_log_path: str | None = None
|
|
654
|
+
if truncated:
|
|
655
|
+
detail = f"Output truncated: showing the last {max_output_bytes} bytes."
|
|
656
|
+
if log_error is not None:
|
|
657
|
+
detail += f"\nFull output could not be saved: {log_error}"
|
|
658
|
+
elif log_name is not None:
|
|
659
|
+
relative_log_path = Path(".deep-agent/logs/exec") / log_name
|
|
660
|
+
host_log_path = str(workspace_root / relative_log_path)
|
|
661
|
+
agent_log_path = f"{SANDBOX_ROOT}/{relative_log_path.as_posix()}"
|
|
662
|
+
detail += f"\nFull output saved to: {host_log_path}"
|
|
663
|
+
detail += f"\nAgent path: {agent_log_path}"
|
|
664
|
+
output = f"{output}\n\n[{detail}]"
|
|
665
|
+
|
|
666
|
+
result_metadata = {
|
|
667
|
+
"host_log_path": host_log_path,
|
|
668
|
+
"agent_log_path": agent_log_path,
|
|
669
|
+
"log_error": log_error,
|
|
670
|
+
"max_output_bytes": max_output_bytes,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if cancelled or (ctx is not None and ctx.cancelled):
|
|
674
|
+
return LocalExecuteResponse(
|
|
675
|
+
f"{output.rstrip()}\n\nCancelled by user.".strip(), 130, truncated,
|
|
676
|
+
termination_reason="cancelled", **result_metadata,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
if timed_out:
|
|
680
|
+
return LocalExecuteResponse(
|
|
681
|
+
f"{output.rstrip()}\n\nError: Command timed out after {timeout} seconds.".strip(),
|
|
682
|
+
124, truncated, termination_reason="timeout", **result_metadata,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if not output:
|
|
686
|
+
output = "<no output>"
|
|
687
|
+
exit_code = int(process.returncode or 0)
|
|
688
|
+
if exit_code:
|
|
689
|
+
output = f"{output.rstrip()}\n\nExit code: {exit_code}"
|
|
690
|
+
return LocalExecuteResponse(output, exit_code, truncated, **result_metadata)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _kill_process_group_pid(pid: int | None) -> None:
|
|
694
|
+
if not pid:
|
|
695
|
+
return
|
|
696
|
+
try:
|
|
697
|
+
os.killpg(pid, signal.SIGKILL)
|
|
698
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
699
|
+
pass
|