weakincentives 0.9.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.
- weakincentives/__init__.py +67 -0
- weakincentives/adapters/__init__.py +37 -0
- weakincentives/adapters/_names.py +32 -0
- weakincentives/adapters/_provider_protocols.py +69 -0
- weakincentives/adapters/_tool_messages.py +80 -0
- weakincentives/adapters/core.py +102 -0
- weakincentives/adapters/litellm.py +254 -0
- weakincentives/adapters/openai.py +254 -0
- weakincentives/adapters/shared.py +1021 -0
- weakincentives/cli/__init__.py +23 -0
- weakincentives/cli/wink.py +58 -0
- weakincentives/dbc/__init__.py +412 -0
- weakincentives/deadlines.py +58 -0
- weakincentives/prompt/__init__.py +105 -0
- weakincentives/prompt/_generic_params_specializer.py +64 -0
- weakincentives/prompt/_normalization.py +48 -0
- weakincentives/prompt/_overrides_protocols.py +33 -0
- weakincentives/prompt/_types.py +34 -0
- weakincentives/prompt/chapter.py +146 -0
- weakincentives/prompt/composition.py +281 -0
- weakincentives/prompt/errors.py +57 -0
- weakincentives/prompt/markdown.py +108 -0
- weakincentives/prompt/overrides/__init__.py +59 -0
- weakincentives/prompt/overrides/_fs.py +164 -0
- weakincentives/prompt/overrides/inspection.py +141 -0
- weakincentives/prompt/overrides/local_store.py +275 -0
- weakincentives/prompt/overrides/validation.py +534 -0
- weakincentives/prompt/overrides/versioning.py +269 -0
- weakincentives/prompt/prompt.py +353 -0
- weakincentives/prompt/protocols.py +103 -0
- weakincentives/prompt/registry.py +375 -0
- weakincentives/prompt/rendering.py +288 -0
- weakincentives/prompt/response_format.py +60 -0
- weakincentives/prompt/section.py +166 -0
- weakincentives/prompt/structured_output.py +179 -0
- weakincentives/prompt/tool.py +397 -0
- weakincentives/prompt/tool_result.py +30 -0
- weakincentives/py.typed +0 -0
- weakincentives/runtime/__init__.py +82 -0
- weakincentives/runtime/events/__init__.py +126 -0
- weakincentives/runtime/events/_types.py +110 -0
- weakincentives/runtime/logging.py +284 -0
- weakincentives/runtime/session/__init__.py +46 -0
- weakincentives/runtime/session/_slice_types.py +24 -0
- weakincentives/runtime/session/_types.py +55 -0
- weakincentives/runtime/session/dataclasses.py +29 -0
- weakincentives/runtime/session/protocols.py +34 -0
- weakincentives/runtime/session/reducer_context.py +40 -0
- weakincentives/runtime/session/reducers.py +82 -0
- weakincentives/runtime/session/selectors.py +56 -0
- weakincentives/runtime/session/session.py +387 -0
- weakincentives/runtime/session/snapshots.py +310 -0
- weakincentives/serde/__init__.py +19 -0
- weakincentives/serde/_utils.py +240 -0
- weakincentives/serde/dataclass_serde.py +55 -0
- weakincentives/serde/dump.py +189 -0
- weakincentives/serde/parse.py +417 -0
- weakincentives/serde/schema.py +260 -0
- weakincentives/tools/__init__.py +154 -0
- weakincentives/tools/_context.py +38 -0
- weakincentives/tools/asteval.py +853 -0
- weakincentives/tools/errors.py +26 -0
- weakincentives/tools/planning.py +831 -0
- weakincentives/tools/podman.py +1655 -0
- weakincentives/tools/subagents.py +346 -0
- weakincentives/tools/vfs.py +1390 -0
- weakincentives/types/__init__.py +35 -0
- weakincentives/types/json.py +45 -0
- weakincentives-0.9.0.dist-info/METADATA +775 -0
- weakincentives-0.9.0.dist-info/RECORD +73 -0
- weakincentives-0.9.0.dist-info/WHEEL +4 -0
- weakincentives-0.9.0.dist-info/entry_points.txt +2 -0
- weakincentives-0.9.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1655 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
"""Podman-backed tool surface exposing the ``shell_execute`` command."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import posixpath
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess # nosec: B404
|
|
24
|
+
import tempfile
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import weakref
|
|
28
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
29
|
+
from contextlib import suppress
|
|
30
|
+
from dataclasses import dataclass, field, replace
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Final, Protocol, cast, runtime_checkable
|
|
34
|
+
|
|
35
|
+
from ..prompt.markdown import MarkdownSection
|
|
36
|
+
from ..prompt.tool import Tool, ToolContext, ToolResult
|
|
37
|
+
from ..runtime.logging import StructuredLogger, get_logger
|
|
38
|
+
from ..runtime.session import Session, replace_latest, select_latest
|
|
39
|
+
from . import vfs as vfs_module
|
|
40
|
+
from ._context import ensure_context_uses_session
|
|
41
|
+
from .asteval import (
|
|
42
|
+
EvalParams,
|
|
43
|
+
EvalResult,
|
|
44
|
+
make_eval_result_reducer,
|
|
45
|
+
)
|
|
46
|
+
from .errors import ToolValidationError
|
|
47
|
+
from .vfs import (
|
|
48
|
+
DeleteEntry,
|
|
49
|
+
EditFileParams,
|
|
50
|
+
FileInfo,
|
|
51
|
+
GlobMatch,
|
|
52
|
+
GlobParams,
|
|
53
|
+
GrepMatch,
|
|
54
|
+
GrepParams,
|
|
55
|
+
HostMount,
|
|
56
|
+
ListDirectoryParams,
|
|
57
|
+
ReadFileParams,
|
|
58
|
+
ReadFileResult,
|
|
59
|
+
RemoveParams,
|
|
60
|
+
VfsPath,
|
|
61
|
+
VirtualFileSystem,
|
|
62
|
+
WriteFile,
|
|
63
|
+
WriteFileParams,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_LOGGER: StructuredLogger = get_logger(__name__, context={"component": "tools.podman"})
|
|
67
|
+
|
|
68
|
+
_DEFAULT_IMAGE: Final[str] = "python:3.12-bookworm"
|
|
69
|
+
_DEFAULT_WORKDIR: Final[str] = "/workspace"
|
|
70
|
+
_DEFAULT_USER: Final[str] = "65534:65534"
|
|
71
|
+
_TMPFS_SIZE: Final[int] = 268_435_456
|
|
72
|
+
_TMPFS_TARGET: Final[str] = tempfile.gettempdir()
|
|
73
|
+
_MAX_STDIO_CHARS: Final[int] = 32 * 1024
|
|
74
|
+
_MAX_COMMAND_LENGTH: Final[int] = 4_096
|
|
75
|
+
_MAX_ENV_LENGTH: Final[int] = 512
|
|
76
|
+
_MAX_ENV_VARS: Final[int] = 64
|
|
77
|
+
_MAX_TIMEOUT: Final[float] = 120.0
|
|
78
|
+
_MIN_TIMEOUT: Final[float] = 1.0
|
|
79
|
+
_DEFAULT_TIMEOUT: Final[float] = 30.0
|
|
80
|
+
_EVAL_TIMEOUT_SECONDS: Final[float] = 5.0
|
|
81
|
+
_MAX_PATH_DEPTH: Final[int] = 16
|
|
82
|
+
_MAX_PATH_SEGMENT: Final[int] = 80
|
|
83
|
+
_EVAL_MAX_STREAM_LENGTH: Final[int] = 4_096
|
|
84
|
+
_ASCII: Final[str] = "ascii"
|
|
85
|
+
_CAPTURE_DISABLED: Final[str] = "capture disabled"
|
|
86
|
+
_CACHE_ENV: Final[str] = "WEAKINCENTIVES_CACHE"
|
|
87
|
+
_PODMAN_BASE_URL_ENV: Final[str] = "PODMAN_BASE_URL"
|
|
88
|
+
_PODMAN_IDENTITY_ENV: Final[str] = "PODMAN_IDENTITY"
|
|
89
|
+
_PODMAN_CONNECTION_ENV: Final[str] = "PODMAN_CONNECTION"
|
|
90
|
+
_CPU_PERIOD: Final[int] = 100_000
|
|
91
|
+
_CPU_QUOTA: Final[int] = 100_000
|
|
92
|
+
_MEMORY_LIMIT: Final[str] = "1g"
|
|
93
|
+
_MISSING_DEPENDENCY_MESSAGE: Final[str] = (
|
|
94
|
+
"Install weakincentives[podman] to enable the Podman tool suite."
|
|
95
|
+
)
|
|
96
|
+
_MAX_MATCH_RESULTS: Final[int] = 2_000
|
|
97
|
+
_REMOVE_PATH_SCRIPT: Final[str] = """
|
|
98
|
+
import shutil
|
|
99
|
+
import sys
|
|
100
|
+
from pathlib import Path
|
|
101
|
+
|
|
102
|
+
target = Path(sys.argv[1])
|
|
103
|
+
if not target.exists():
|
|
104
|
+
raise SystemExit(3)
|
|
105
|
+
if target.is_symlink():
|
|
106
|
+
target.unlink()
|
|
107
|
+
elif target.is_dir():
|
|
108
|
+
shutil.rmtree(target)
|
|
109
|
+
else:
|
|
110
|
+
target.unlink()
|
|
111
|
+
"""
|
|
112
|
+
_PODMAN_TEMPLATE: Final[str] = """\
|
|
113
|
+
Podman Workspace
|
|
114
|
+
----------------
|
|
115
|
+
You have access to an isolated Linux container powered by Podman. The `ls`,
|
|
116
|
+
`read_file`, `write_file`, `glob`, `grep`, and `rm` tools mirror the virtual
|
|
117
|
+
filesystem interface but operate on `/workspace` inside the container. The
|
|
118
|
+
`evaluate_python` tool is a thin wrapper around `python3 -c` (≤5 seconds); read
|
|
119
|
+
and edit files directly from the workspace. `shell_execute` runs short commands
|
|
120
|
+
(≤120 seconds) in the shared environment. No network access or privileged
|
|
121
|
+
operations are available. Do not assume files outside `/workspace` exist."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@runtime_checkable
|
|
125
|
+
class _PodmanClient(Protocol):
|
|
126
|
+
"""Subset of :class:`podman.PodmanClient` used by the section."""
|
|
127
|
+
|
|
128
|
+
containers: Any
|
|
129
|
+
images: Any
|
|
130
|
+
|
|
131
|
+
def close(self) -> None: ...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
type _ClientFactory = Callable[[], _PodmanClient]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@runtime_checkable
|
|
138
|
+
class _ExecRunner(Protocol):
|
|
139
|
+
def __call__(
|
|
140
|
+
self,
|
|
141
|
+
cmd: list[str],
|
|
142
|
+
*,
|
|
143
|
+
input: str | None = None, # noqa: A002 - matches subprocess API
|
|
144
|
+
text: bool | None = None,
|
|
145
|
+
capture_output: bool | None = None,
|
|
146
|
+
timeout: float | None = None,
|
|
147
|
+
) -> subprocess.CompletedProcess[str]: ...
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(slots=True, frozen=True)
|
|
151
|
+
class _PodmanSectionParams:
|
|
152
|
+
image: str = _DEFAULT_IMAGE
|
|
153
|
+
workspace_root: str = _DEFAULT_WORKDIR
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(slots=True, frozen=True)
|
|
157
|
+
class PodmanShellParams:
|
|
158
|
+
"""Parameter payload accepted by the ``shell_execute`` tool."""
|
|
159
|
+
|
|
160
|
+
command: tuple[str, ...]
|
|
161
|
+
cwd: str | None = None
|
|
162
|
+
env: Mapping[str, str] = field(default_factory=lambda: dict[str, str]())
|
|
163
|
+
stdin: str | None = None
|
|
164
|
+
timeout_seconds: float = _DEFAULT_TIMEOUT
|
|
165
|
+
capture_output: bool = True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(slots=True, frozen=True)
|
|
169
|
+
class PodmanShellResult:
|
|
170
|
+
"""Structured command summary returned by the ``shell_execute`` tool."""
|
|
171
|
+
|
|
172
|
+
command: tuple[str, ...]
|
|
173
|
+
cwd: str
|
|
174
|
+
exit_code: int
|
|
175
|
+
stdout: str
|
|
176
|
+
stderr: str
|
|
177
|
+
duration_ms: int
|
|
178
|
+
timed_out: bool
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(slots=True, frozen=True)
|
|
182
|
+
class PodmanWorkspace:
|
|
183
|
+
"""Active Podman container backing the session."""
|
|
184
|
+
|
|
185
|
+
container_id: str
|
|
186
|
+
container_name: str
|
|
187
|
+
image: str
|
|
188
|
+
workdir: str
|
|
189
|
+
overlay_path: str
|
|
190
|
+
env: tuple[tuple[str, str], ...]
|
|
191
|
+
started_at: datetime
|
|
192
|
+
last_used_at: datetime
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(slots=True)
|
|
196
|
+
class _WorkspaceHandle:
|
|
197
|
+
descriptor: PodmanWorkspace
|
|
198
|
+
overlay_path: Path
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(slots=True, frozen=True)
|
|
202
|
+
class _PodmanConnectionInfo:
|
|
203
|
+
base_url: str | None
|
|
204
|
+
identity: str | None
|
|
205
|
+
connection_name: str | None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(slots=True, frozen=True)
|
|
209
|
+
class _ResolvedHostMount:
|
|
210
|
+
source_label: str
|
|
211
|
+
resolved_host: Path
|
|
212
|
+
mount_path: VfsPath
|
|
213
|
+
include_glob: tuple[str, ...]
|
|
214
|
+
exclude_glob: tuple[str, ...]
|
|
215
|
+
max_bytes: int | None
|
|
216
|
+
follow_symlinks: bool
|
|
217
|
+
preview: vfs_module.HostMountPreview
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _default_cache_root() -> Path:
|
|
221
|
+
override = os.environ.get(_CACHE_ENV)
|
|
222
|
+
if override:
|
|
223
|
+
return Path(override).expanduser()
|
|
224
|
+
return Path.home() / ".cache" / "weakincentives" / "podman"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _resolve_podman_connection(
|
|
228
|
+
*,
|
|
229
|
+
preferred_name: str | None = None,
|
|
230
|
+
) -> _PodmanConnectionInfo | None:
|
|
231
|
+
env_base_url = os.environ.get(_PODMAN_BASE_URL_ENV)
|
|
232
|
+
env_identity = os.environ.get(_PODMAN_IDENTITY_ENV)
|
|
233
|
+
env_connection = os.environ.get(_PODMAN_CONNECTION_ENV)
|
|
234
|
+
if env_base_url or env_identity:
|
|
235
|
+
return _PodmanConnectionInfo(
|
|
236
|
+
base_url=env_base_url,
|
|
237
|
+
identity=env_identity,
|
|
238
|
+
connection_name=preferred_name or env_connection,
|
|
239
|
+
)
|
|
240
|
+
resolved_name = preferred_name or env_connection
|
|
241
|
+
return _connection_from_cli(resolved_name)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _connection_from_cli(
|
|
245
|
+
connection_name: str | None,
|
|
246
|
+
) -> _PodmanConnectionInfo | None:
|
|
247
|
+
try:
|
|
248
|
+
result = subprocess.run( # nosec B603 B607
|
|
249
|
+
["podman", "system", "connection", "list", "--format", "json"],
|
|
250
|
+
capture_output=True,
|
|
251
|
+
text=True,
|
|
252
|
+
check=True,
|
|
253
|
+
)
|
|
254
|
+
except (OSError, subprocess.CalledProcessError):
|
|
255
|
+
return None
|
|
256
|
+
try:
|
|
257
|
+
connections = json.loads(result.stdout)
|
|
258
|
+
except json.JSONDecodeError:
|
|
259
|
+
return None
|
|
260
|
+
candidate: dict[str, Any] | None = None
|
|
261
|
+
if connection_name:
|
|
262
|
+
for entry in connections:
|
|
263
|
+
if entry.get("Name") == connection_name:
|
|
264
|
+
candidate = entry
|
|
265
|
+
break
|
|
266
|
+
else:
|
|
267
|
+
for entry in connections:
|
|
268
|
+
if entry.get("Default"):
|
|
269
|
+
candidate = entry
|
|
270
|
+
break
|
|
271
|
+
if candidate is None and connections:
|
|
272
|
+
candidate = connections[0]
|
|
273
|
+
if candidate is None:
|
|
274
|
+
return None
|
|
275
|
+
return _PodmanConnectionInfo(
|
|
276
|
+
base_url=candidate.get("URI"),
|
|
277
|
+
identity=candidate.get("Identity"),
|
|
278
|
+
connection_name=candidate.get("Name"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _resolve_podman_host_mounts(
|
|
283
|
+
mounts: Sequence[HostMount],
|
|
284
|
+
allowed_roots: Sequence[Path],
|
|
285
|
+
) -> tuple[tuple[_ResolvedHostMount, ...], tuple[vfs_module.HostMountPreview, ...]]:
|
|
286
|
+
if not mounts:
|
|
287
|
+
return (), ()
|
|
288
|
+
resolved: list[_ResolvedHostMount] = []
|
|
289
|
+
previews: list[vfs_module.HostMountPreview] = []
|
|
290
|
+
for mount in mounts:
|
|
291
|
+
spec = _resolve_single_host_mount(mount, allowed_roots)
|
|
292
|
+
resolved.append(spec)
|
|
293
|
+
previews.append(spec.preview)
|
|
294
|
+
return tuple(resolved), tuple(previews)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _resolve_single_host_mount(
|
|
298
|
+
mount: HostMount,
|
|
299
|
+
allowed_roots: Sequence[Path],
|
|
300
|
+
) -> _ResolvedHostMount:
|
|
301
|
+
host_path = mount.host_path.strip()
|
|
302
|
+
if not host_path:
|
|
303
|
+
raise ToolValidationError("Host mount path must not be empty.")
|
|
304
|
+
vfs_module.ensure_ascii(host_path, "host path")
|
|
305
|
+
resolved_host = _resolve_host_path(host_path, allowed_roots)
|
|
306
|
+
include_glob = _normalize_mount_globs(mount.include_glob, "include_glob")
|
|
307
|
+
exclude_glob = _normalize_mount_globs(mount.exclude_glob, "exclude_glob")
|
|
308
|
+
mount_path = (
|
|
309
|
+
vfs_module.normalize_path(mount.mount_path)
|
|
310
|
+
if mount.mount_path is not None
|
|
311
|
+
else VfsPath(())
|
|
312
|
+
)
|
|
313
|
+
preview_entries = _preview_mount_entries(resolved_host)
|
|
314
|
+
preview = vfs_module.HostMountPreview(
|
|
315
|
+
host_path=host_path,
|
|
316
|
+
resolved_host=resolved_host,
|
|
317
|
+
mount_path=mount_path,
|
|
318
|
+
entries=preview_entries,
|
|
319
|
+
is_directory=resolved_host.is_dir(),
|
|
320
|
+
)
|
|
321
|
+
return _ResolvedHostMount(
|
|
322
|
+
source_label=host_path,
|
|
323
|
+
resolved_host=resolved_host,
|
|
324
|
+
mount_path=mount_path,
|
|
325
|
+
include_glob=include_glob,
|
|
326
|
+
exclude_glob=exclude_glob,
|
|
327
|
+
max_bytes=mount.max_bytes,
|
|
328
|
+
follow_symlinks=mount.follow_symlinks,
|
|
329
|
+
preview=preview,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _resolve_host_path(host_path: str, allowed_roots: Sequence[Path]) -> Path:
|
|
334
|
+
if not allowed_roots:
|
|
335
|
+
raise ToolValidationError("No allowed host roots configured for mounts.")
|
|
336
|
+
for root in allowed_roots:
|
|
337
|
+
candidate = (root / host_path).expanduser().resolve()
|
|
338
|
+
try:
|
|
339
|
+
_ = candidate.relative_to(root)
|
|
340
|
+
except ValueError:
|
|
341
|
+
continue
|
|
342
|
+
if candidate.exists():
|
|
343
|
+
return candidate
|
|
344
|
+
raise ToolValidationError("Host path is outside the allowed roots or missing.")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _normalize_mount_globs(patterns: Sequence[str], field: str) -> tuple[str, ...]:
|
|
348
|
+
normalized: list[str] = []
|
|
349
|
+
for pattern in patterns:
|
|
350
|
+
stripped = pattern.strip()
|
|
351
|
+
if not stripped:
|
|
352
|
+
continue
|
|
353
|
+
vfs_module.ensure_ascii(stripped, field)
|
|
354
|
+
normalized.append(stripped)
|
|
355
|
+
return tuple(normalized)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _preview_mount_entries(root: Path) -> tuple[str, ...]:
|
|
359
|
+
if root.is_file():
|
|
360
|
+
return (root.name,)
|
|
361
|
+
try:
|
|
362
|
+
children = sorted(root.iterdir(), key=lambda path: path.name.lower())
|
|
363
|
+
except OSError as error:
|
|
364
|
+
raise ToolValidationError(f"Failed to inspect host mount {root}.") from error
|
|
365
|
+
labels: list[str] = []
|
|
366
|
+
for child in children:
|
|
367
|
+
suffix = "/" if child.is_dir() else ""
|
|
368
|
+
labels.append(f"{child.name}{suffix}")
|
|
369
|
+
return tuple(labels)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _iter_host_mount_files(root: Path, follow_symlinks: bool) -> Iterator[Path]:
|
|
373
|
+
if root.is_file():
|
|
374
|
+
yield root
|
|
375
|
+
return
|
|
376
|
+
for dirpath, _dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
|
|
377
|
+
current = Path(dirpath)
|
|
378
|
+
for name in filenames:
|
|
379
|
+
yield current / name
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _default_exec_runner(
|
|
383
|
+
cmd: list[str],
|
|
384
|
+
*,
|
|
385
|
+
input: str | None = None, # noqa: A002 - matches subprocess API
|
|
386
|
+
text: bool | None = None,
|
|
387
|
+
capture_output: bool | None = None,
|
|
388
|
+
timeout: float | None = None,
|
|
389
|
+
) -> subprocess.CompletedProcess[str]:
|
|
390
|
+
completed = subprocess.run( # nosec: B603
|
|
391
|
+
cmd,
|
|
392
|
+
input=input,
|
|
393
|
+
text=True if text is None else text,
|
|
394
|
+
capture_output=True if capture_output is None else capture_output,
|
|
395
|
+
timeout=timeout,
|
|
396
|
+
)
|
|
397
|
+
return cast(subprocess.CompletedProcess[str], completed)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _build_client_factory(
|
|
401
|
+
*,
|
|
402
|
+
base_url: str | None,
|
|
403
|
+
identity: str | None,
|
|
404
|
+
) -> _ClientFactory:
|
|
405
|
+
def _factory() -> _PodmanClient:
|
|
406
|
+
try:
|
|
407
|
+
from podman import PodmanClient
|
|
408
|
+
except ModuleNotFoundError as error: # pragma: no cover - configuration guard
|
|
409
|
+
raise RuntimeError(_MISSING_DEPENDENCY_MESSAGE) from error
|
|
410
|
+
|
|
411
|
+
kwargs: dict[str, str] = {}
|
|
412
|
+
if base_url is not None:
|
|
413
|
+
kwargs["base_url"] = base_url
|
|
414
|
+
if identity is not None:
|
|
415
|
+
kwargs["identity"] = identity
|
|
416
|
+
return PodmanClient(**kwargs)
|
|
417
|
+
|
|
418
|
+
return _factory
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _ensure_ascii(value: str, *, field: str) -> str:
|
|
422
|
+
try:
|
|
423
|
+
_ = value.encode(_ASCII)
|
|
424
|
+
except UnicodeEncodeError as error:
|
|
425
|
+
raise ToolValidationError(f"{field} must be ASCII.") from error
|
|
426
|
+
return value
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _normalize_command(command: tuple[str, ...]) -> tuple[str, ...]:
|
|
430
|
+
if not command:
|
|
431
|
+
raise ToolValidationError("command must contain at least one entry.")
|
|
432
|
+
total_length = 0
|
|
433
|
+
normalized: list[str] = []
|
|
434
|
+
for index, entry in enumerate(command):
|
|
435
|
+
if not entry:
|
|
436
|
+
raise ToolValidationError(f"command[{index}] must not be empty.")
|
|
437
|
+
normalized_entry = _ensure_ascii(entry, field="command")
|
|
438
|
+
total_length += len(normalized_entry)
|
|
439
|
+
if total_length > _MAX_COMMAND_LENGTH:
|
|
440
|
+
raise ToolValidationError("command is too long (limit 4,096 characters).")
|
|
441
|
+
normalized.append(normalized_entry)
|
|
442
|
+
return tuple(normalized)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _normalize_env(env: Mapping[str, str]) -> dict[str, str]:
|
|
446
|
+
if len(env) > _MAX_ENV_VARS:
|
|
447
|
+
raise ToolValidationError("env contains too many entries (max 64).")
|
|
448
|
+
normalized: dict[str, str] = {}
|
|
449
|
+
for key, value in env.items():
|
|
450
|
+
normalized_key = _ensure_ascii(key, field="env key").upper()
|
|
451
|
+
if not normalized_key:
|
|
452
|
+
raise ToolValidationError("env keys must not be empty.")
|
|
453
|
+
if len(normalized_key) > _MAX_PATH_SEGMENT:
|
|
454
|
+
raise ToolValidationError(
|
|
455
|
+
f"env key {normalized_key!r} is longer than {_MAX_PATH_SEGMENT} characters."
|
|
456
|
+
)
|
|
457
|
+
normalized_value = _ensure_ascii(value, field="env value")
|
|
458
|
+
if len(normalized_value) > _MAX_ENV_LENGTH:
|
|
459
|
+
raise ToolValidationError(
|
|
460
|
+
f"env value for {normalized_key!r} exceeds {_MAX_ENV_LENGTH} characters."
|
|
461
|
+
)
|
|
462
|
+
normalized[normalized_key] = normalized_value
|
|
463
|
+
return normalized
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _normalize_timeout(timeout_seconds: float) -> float:
|
|
467
|
+
if timeout_seconds != timeout_seconds: # NaN guard
|
|
468
|
+
raise ToolValidationError("timeout_seconds must be a real number.")
|
|
469
|
+
return max(_MIN_TIMEOUT, min(_MAX_TIMEOUT, timeout_seconds))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _normalize_cwd(path: str | None) -> str:
|
|
473
|
+
if path is None or path == "":
|
|
474
|
+
return _DEFAULT_WORKDIR
|
|
475
|
+
stripped = path.strip()
|
|
476
|
+
if stripped.startswith("/"):
|
|
477
|
+
raise ToolValidationError("cwd must be relative to /workspace.")
|
|
478
|
+
parts = [segment for segment in stripped.split("/") if segment]
|
|
479
|
+
if len(parts) > _MAX_PATH_DEPTH:
|
|
480
|
+
raise ToolValidationError("cwd exceeds maximum depth of 16 segments.")
|
|
481
|
+
normalized_segments: list[str] = []
|
|
482
|
+
for segment in parts:
|
|
483
|
+
if segment in {".", ".."}:
|
|
484
|
+
raise ToolValidationError("cwd must not contain '.' or '..' segments.")
|
|
485
|
+
if len(segment) > _MAX_PATH_SEGMENT:
|
|
486
|
+
raise ToolValidationError(
|
|
487
|
+
f"cwd segment {segment!r} exceeds {_MAX_PATH_SEGMENT} characters."
|
|
488
|
+
)
|
|
489
|
+
normalized_segment = _ensure_ascii(segment, field="cwd")
|
|
490
|
+
normalized_segments.append(normalized_segment)
|
|
491
|
+
if not normalized_segments:
|
|
492
|
+
return _DEFAULT_WORKDIR
|
|
493
|
+
return posixpath.join(_DEFAULT_WORKDIR, *normalized_segments)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _truncate_stream(value: str) -> str:
|
|
497
|
+
if len(value) <= _MAX_STDIO_CHARS:
|
|
498
|
+
return value
|
|
499
|
+
truncated = value[: _MAX_STDIO_CHARS - len("[truncated]")]
|
|
500
|
+
return f"{truncated}[truncated]"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _truncate_eval_stream(value: str) -> str:
|
|
504
|
+
if len(value) <= _EVAL_MAX_STREAM_LENGTH:
|
|
505
|
+
return value
|
|
506
|
+
suffix = "..."
|
|
507
|
+
keep = _EVAL_MAX_STREAM_LENGTH - len(suffix)
|
|
508
|
+
return f"{value[:keep]}{suffix}"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _normalize_podman_eval_code(code: str) -> str:
|
|
512
|
+
for char in code:
|
|
513
|
+
code_point = ord(char)
|
|
514
|
+
if code_point < 32 and char not in {"\n", "\t"}:
|
|
515
|
+
raise ToolValidationError("Code contains unsupported control characters.")
|
|
516
|
+
return code
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class PodmanSandboxSection(MarkdownSection[_PodmanSectionParams]):
|
|
520
|
+
"""Prompt section exposing the Podman ``shell_execute`` tool."""
|
|
521
|
+
|
|
522
|
+
def __init__(
|
|
523
|
+
self,
|
|
524
|
+
*,
|
|
525
|
+
session: Session,
|
|
526
|
+
image: str = _DEFAULT_IMAGE,
|
|
527
|
+
mounts: Sequence[HostMount] = (),
|
|
528
|
+
allowed_host_roots: Sequence[os.PathLike[str] | str] = (),
|
|
529
|
+
base_url: str | None = None,
|
|
530
|
+
identity: str | os.PathLike[str] | None = None,
|
|
531
|
+
base_environment: Mapping[str, str] | None = None,
|
|
532
|
+
cache_dir: os.PathLike[str] | str | None = None,
|
|
533
|
+
client_factory: _ClientFactory | None = None,
|
|
534
|
+
clock: Callable[[], datetime] | None = None,
|
|
535
|
+
connection_name: str | None = None,
|
|
536
|
+
exec_runner: _ExecRunner | None = None,
|
|
537
|
+
accepts_overrides: bool = False,
|
|
538
|
+
) -> None:
|
|
539
|
+
self._session = session
|
|
540
|
+
self._image = image
|
|
541
|
+
env_connection = os.environ.get(_PODMAN_CONNECTION_ENV)
|
|
542
|
+
preferred_connection = connection_name or env_connection
|
|
543
|
+
resolved_connection: _PodmanConnectionInfo | None = None
|
|
544
|
+
if base_url is None or identity is None:
|
|
545
|
+
resolved_connection = _resolve_podman_connection(
|
|
546
|
+
preferred_name=preferred_connection
|
|
547
|
+
)
|
|
548
|
+
if base_url is None and resolved_connection is not None:
|
|
549
|
+
base_url = resolved_connection.base_url
|
|
550
|
+
if identity is None and resolved_connection is not None:
|
|
551
|
+
identity = resolved_connection.identity
|
|
552
|
+
if connection_name is None:
|
|
553
|
+
if resolved_connection is not None:
|
|
554
|
+
connection_name = resolved_connection.connection_name
|
|
555
|
+
else:
|
|
556
|
+
connection_name = env_connection
|
|
557
|
+
if base_url is None:
|
|
558
|
+
message = (
|
|
559
|
+
"Podman connection could not be resolved. Configure `podman system connection` {}"
|
|
560
|
+
).format("or set PODMAN_BASE_URL/PODMAN_IDENTITY.")
|
|
561
|
+
raise ToolValidationError(message)
|
|
562
|
+
identity_str = str(identity) if identity is not None else None
|
|
563
|
+
self._client_factory = client_factory or _build_client_factory(
|
|
564
|
+
base_url=base_url,
|
|
565
|
+
identity=identity_str,
|
|
566
|
+
)
|
|
567
|
+
self._base_env = tuple(
|
|
568
|
+
sorted((base_environment or {}).items(), key=lambda item: item[0])
|
|
569
|
+
)
|
|
570
|
+
self._overlay_root = (
|
|
571
|
+
Path(cache_dir).expanduser()
|
|
572
|
+
if cache_dir is not None
|
|
573
|
+
else _default_cache_root()
|
|
574
|
+
)
|
|
575
|
+
self._overlay_root.mkdir(parents=True, exist_ok=True)
|
|
576
|
+
allowed_roots = tuple(
|
|
577
|
+
vfs_module.normalize_host_root(path) for path in allowed_host_roots
|
|
578
|
+
)
|
|
579
|
+
(
|
|
580
|
+
self._resolved_mounts,
|
|
581
|
+
self._mount_previews,
|
|
582
|
+
) = _resolve_podman_host_mounts(mounts, allowed_roots)
|
|
583
|
+
self._mount_snapshot = VirtualFileSystem()
|
|
584
|
+
self._clock = clock or (lambda: datetime.now(UTC))
|
|
585
|
+
self._workspace_handle: _WorkspaceHandle | None = None
|
|
586
|
+
self._lock = threading.RLock()
|
|
587
|
+
self._connection_name = connection_name
|
|
588
|
+
self._exec_runner: _ExecRunner = exec_runner or _default_exec_runner
|
|
589
|
+
self._finalizer = weakref.finalize(
|
|
590
|
+
self, PodmanSandboxSection._cleanup_from_finalizer, weakref.ref(self)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
session.register_reducer(PodmanWorkspace, replace_latest)
|
|
594
|
+
self._initialize_vfs_state(session)
|
|
595
|
+
session.register_reducer(
|
|
596
|
+
EvalResult,
|
|
597
|
+
make_eval_result_reducer(),
|
|
598
|
+
slice_type=VirtualFileSystem,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
self._vfs_suite = _PodmanVfsSuite(section=self)
|
|
602
|
+
self._shell_suite = _PodmanShellSuite(section=self)
|
|
603
|
+
self._eval_suite = _PodmanEvalSuite(section=self)
|
|
604
|
+
tools = (
|
|
605
|
+
Tool[ListDirectoryParams, tuple[FileInfo, ...]](
|
|
606
|
+
name="ls",
|
|
607
|
+
description="List directory entries under a relative path.",
|
|
608
|
+
handler=self._vfs_suite.list_directory,
|
|
609
|
+
accepts_overrides=accepts_overrides,
|
|
610
|
+
),
|
|
611
|
+
Tool[ReadFileParams, ReadFileResult](
|
|
612
|
+
name="read_file",
|
|
613
|
+
description="Read UTF-8 file contents with pagination support.",
|
|
614
|
+
handler=self._vfs_suite.read_file,
|
|
615
|
+
accepts_overrides=accepts_overrides,
|
|
616
|
+
),
|
|
617
|
+
Tool[WriteFileParams, WriteFile](
|
|
618
|
+
name="write_file",
|
|
619
|
+
description="Create a new UTF-8 text file.",
|
|
620
|
+
handler=self._vfs_suite.write_file,
|
|
621
|
+
accepts_overrides=accepts_overrides,
|
|
622
|
+
),
|
|
623
|
+
Tool[EditFileParams, WriteFile](
|
|
624
|
+
name="edit_file",
|
|
625
|
+
description="Replace occurrences of a string within a file.",
|
|
626
|
+
handler=self._vfs_suite.edit_file,
|
|
627
|
+
accepts_overrides=accepts_overrides,
|
|
628
|
+
),
|
|
629
|
+
Tool[GlobParams, tuple[GlobMatch, ...]](
|
|
630
|
+
name="glob",
|
|
631
|
+
description="Match files beneath a directory using shell patterns.",
|
|
632
|
+
handler=self._vfs_suite.glob,
|
|
633
|
+
accepts_overrides=accepts_overrides,
|
|
634
|
+
),
|
|
635
|
+
Tool[GrepParams, tuple[GrepMatch, ...]](
|
|
636
|
+
name="grep",
|
|
637
|
+
description="Search files for a regular expression pattern.",
|
|
638
|
+
handler=self._vfs_suite.grep,
|
|
639
|
+
accepts_overrides=accepts_overrides,
|
|
640
|
+
),
|
|
641
|
+
Tool[RemoveParams, DeleteEntry](
|
|
642
|
+
name="rm",
|
|
643
|
+
description="Remove files or directories recursively.",
|
|
644
|
+
handler=self._vfs_suite.remove,
|
|
645
|
+
accepts_overrides=accepts_overrides,
|
|
646
|
+
),
|
|
647
|
+
Tool[PodmanShellParams, PodmanShellResult](
|
|
648
|
+
name="shell_execute",
|
|
649
|
+
description="Run a short command inside the Podman workspace.",
|
|
650
|
+
handler=self._shell_suite.run_shell,
|
|
651
|
+
accepts_overrides=accepts_overrides,
|
|
652
|
+
),
|
|
653
|
+
Tool[EvalParams, EvalResult](
|
|
654
|
+
name="evaluate_python",
|
|
655
|
+
description=(
|
|
656
|
+
"Run a short Python script via `python3 -c` inside the Podman workspace. "
|
|
657
|
+
"Captures stdout/stderr and reports the exit code."
|
|
658
|
+
),
|
|
659
|
+
handler=self._eval_suite.evaluate_python,
|
|
660
|
+
accepts_overrides=accepts_overrides,
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
template = _PODMAN_TEMPLATE
|
|
664
|
+
mounts_block = vfs_module.render_host_mounts_block(self._mount_previews)
|
|
665
|
+
if mounts_block:
|
|
666
|
+
template = f"{_PODMAN_TEMPLATE}\n\n{mounts_block}"
|
|
667
|
+
super().__init__(
|
|
668
|
+
title="Podman Workspace",
|
|
669
|
+
key="podman.shell",
|
|
670
|
+
template=template,
|
|
671
|
+
default_params=_PodmanSectionParams(
|
|
672
|
+
image=image, workspace_root=_DEFAULT_WORKDIR
|
|
673
|
+
),
|
|
674
|
+
tools=tools,
|
|
675
|
+
accepts_overrides=accepts_overrides,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
@property
|
|
679
|
+
def session(self) -> Session:
|
|
680
|
+
return self._session
|
|
681
|
+
|
|
682
|
+
def _initialize_vfs_state(self, session: Session) -> None:
|
|
683
|
+
session.register_reducer(VirtualFileSystem, replace_latest)
|
|
684
|
+
session.seed_slice(VirtualFileSystem, (self._mount_snapshot,))
|
|
685
|
+
session.register_reducer(
|
|
686
|
+
WriteFile,
|
|
687
|
+
vfs_module.make_write_reducer(),
|
|
688
|
+
slice_type=VirtualFileSystem,
|
|
689
|
+
)
|
|
690
|
+
session.register_reducer(
|
|
691
|
+
DeleteEntry,
|
|
692
|
+
vfs_module.make_delete_reducer(),
|
|
693
|
+
slice_type=VirtualFileSystem,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def latest_snapshot(self) -> VirtualFileSystem:
|
|
697
|
+
snapshot = select_latest(self._session, VirtualFileSystem)
|
|
698
|
+
return snapshot or self._mount_snapshot
|
|
699
|
+
|
|
700
|
+
@staticmethod
|
|
701
|
+
def resolve_connection(
|
|
702
|
+
connection_name: str | None = None,
|
|
703
|
+
) -> dict[str, str | None] | None:
|
|
704
|
+
resolved = _resolve_podman_connection(preferred_name=connection_name)
|
|
705
|
+
if resolved is None:
|
|
706
|
+
return None
|
|
707
|
+
return {
|
|
708
|
+
"base_url": resolved.base_url,
|
|
709
|
+
"identity": resolved.identity,
|
|
710
|
+
"connection_name": resolved.connection_name,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
def _ensure_workspace(self) -> _WorkspaceHandle:
|
|
714
|
+
with self._lock:
|
|
715
|
+
if self._workspace_handle is not None:
|
|
716
|
+
return self._workspace_handle
|
|
717
|
+
handle = self._create_workspace()
|
|
718
|
+
self._workspace_handle = handle
|
|
719
|
+
self._session.seed_slice(PodmanWorkspace, (handle.descriptor,))
|
|
720
|
+
return handle
|
|
721
|
+
|
|
722
|
+
def _create_workspace(self) -> _WorkspaceHandle:
|
|
723
|
+
client = self._client_factory()
|
|
724
|
+
overlay = self._workspace_overlay_path()
|
|
725
|
+
overlay.mkdir(parents=True, exist_ok=True)
|
|
726
|
+
self._hydrate_overlay_mounts(overlay)
|
|
727
|
+
_LOGGER.info(
|
|
728
|
+
"Creating Podman workspace",
|
|
729
|
+
event="podman.workspace.create",
|
|
730
|
+
context={"overlay": str(overlay), "image": self._image},
|
|
731
|
+
)
|
|
732
|
+
_ = client.images.pull(self._image)
|
|
733
|
+
env = _normalize_env(dict(self._base_env))
|
|
734
|
+
name = f"wink-{self._session.session_id}"
|
|
735
|
+
container = client.containers.create(
|
|
736
|
+
image=self._image,
|
|
737
|
+
command=["sleep", "infinity"],
|
|
738
|
+
name=name,
|
|
739
|
+
workdir=_DEFAULT_WORKDIR,
|
|
740
|
+
user=_DEFAULT_USER,
|
|
741
|
+
mem_limit=_MEMORY_LIMIT,
|
|
742
|
+
memswap_limit=_MEMORY_LIMIT,
|
|
743
|
+
cpu_period=_CPU_PERIOD,
|
|
744
|
+
cpu_quota=_CPU_QUOTA,
|
|
745
|
+
environment=env,
|
|
746
|
+
mounts=[
|
|
747
|
+
{
|
|
748
|
+
"Target": _TMPFS_TARGET,
|
|
749
|
+
"Type": "tmpfs",
|
|
750
|
+
"TmpfsOptions": {"SizeBytes": _TMPFS_SIZE},
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
"Target": _DEFAULT_WORKDIR,
|
|
754
|
+
"Source": str(overlay),
|
|
755
|
+
"Type": "bind",
|
|
756
|
+
"Options": ["rbind", "rw"],
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
remove=True,
|
|
760
|
+
)
|
|
761
|
+
container.start()
|
|
762
|
+
exit_code, _output = container.exec_run(
|
|
763
|
+
["test", "-d", _DEFAULT_WORKDIR],
|
|
764
|
+
stdout=True,
|
|
765
|
+
stderr=True,
|
|
766
|
+
demux=True,
|
|
767
|
+
)
|
|
768
|
+
if exit_code != 0:
|
|
769
|
+
raise ToolValidationError("Podman workspace failed readiness checks.")
|
|
770
|
+
now = self._clock().astimezone(UTC)
|
|
771
|
+
descriptor = PodmanWorkspace(
|
|
772
|
+
container_id=container.id,
|
|
773
|
+
container_name=name,
|
|
774
|
+
image=self._image,
|
|
775
|
+
workdir=_DEFAULT_WORKDIR,
|
|
776
|
+
overlay_path=str(overlay),
|
|
777
|
+
env=tuple(sorted(env.items())),
|
|
778
|
+
started_at=now,
|
|
779
|
+
last_used_at=now,
|
|
780
|
+
)
|
|
781
|
+
return _WorkspaceHandle(descriptor=descriptor, overlay_path=overlay)
|
|
782
|
+
|
|
783
|
+
def _workspace_overlay_path(self) -> Path:
|
|
784
|
+
return self._overlay_root / str(self._session.session_id)
|
|
785
|
+
|
|
786
|
+
def _hydrate_overlay_mounts(self, overlay: Path) -> None:
|
|
787
|
+
if not self._resolved_mounts:
|
|
788
|
+
return
|
|
789
|
+
iterator = overlay.iterdir()
|
|
790
|
+
try:
|
|
791
|
+
_ = next(iterator)
|
|
792
|
+
except StopIteration:
|
|
793
|
+
pass
|
|
794
|
+
else:
|
|
795
|
+
return
|
|
796
|
+
for mount in self._resolved_mounts:
|
|
797
|
+
self._copy_mount_into_overlay(overlay=overlay, mount=mount)
|
|
798
|
+
|
|
799
|
+
def _workspace_env(self) -> dict[str, str]:
|
|
800
|
+
return (
|
|
801
|
+
dict(self._workspace_handle.descriptor.env)
|
|
802
|
+
if self._workspace_handle
|
|
803
|
+
else dict(self._base_env)
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
def _copy_mount_into_overlay(
|
|
807
|
+
self,
|
|
808
|
+
*,
|
|
809
|
+
overlay: Path,
|
|
810
|
+
mount: _ResolvedHostMount,
|
|
811
|
+
) -> None:
|
|
812
|
+
base_target = _host_path_for(overlay, mount.mount_path)
|
|
813
|
+
consumed_bytes = 0
|
|
814
|
+
source = mount.resolved_host
|
|
815
|
+
for file_path in _iter_host_mount_files(source, mount.follow_symlinks):
|
|
816
|
+
relative = (
|
|
817
|
+
Path(file_path.name)
|
|
818
|
+
if source.is_file()
|
|
819
|
+
else file_path.relative_to(source)
|
|
820
|
+
)
|
|
821
|
+
relative_label = relative.as_posix()
|
|
822
|
+
if mount.include_glob and not any(
|
|
823
|
+
fnmatch.fnmatchcase(relative_label, pattern)
|
|
824
|
+
for pattern in mount.include_glob
|
|
825
|
+
):
|
|
826
|
+
continue
|
|
827
|
+
if any(
|
|
828
|
+
fnmatch.fnmatchcase(relative_label, pattern)
|
|
829
|
+
for pattern in mount.exclude_glob
|
|
830
|
+
):
|
|
831
|
+
continue
|
|
832
|
+
target = base_target / relative
|
|
833
|
+
_assert_within_overlay(overlay, target)
|
|
834
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
835
|
+
try:
|
|
836
|
+
size = file_path.stat().st_size
|
|
837
|
+
except OSError as error:
|
|
838
|
+
raise ToolValidationError(
|
|
839
|
+
f"Failed to stat mounted file {file_path}."
|
|
840
|
+
) from error
|
|
841
|
+
if mount.max_bytes is not None and consumed_bytes + size > mount.max_bytes:
|
|
842
|
+
raise ToolValidationError(
|
|
843
|
+
"Host mount exceeded the configured byte budget."
|
|
844
|
+
)
|
|
845
|
+
consumed_bytes += size
|
|
846
|
+
try:
|
|
847
|
+
_ = shutil.copy2(file_path, target)
|
|
848
|
+
except OSError as error:
|
|
849
|
+
raise ToolValidationError(
|
|
850
|
+
"Failed to materialize host mounts inside the Podman workspace."
|
|
851
|
+
) from error
|
|
852
|
+
|
|
853
|
+
def _touch_workspace(self) -> None:
|
|
854
|
+
with self._lock:
|
|
855
|
+
handle = self._workspace_handle
|
|
856
|
+
if handle is None:
|
|
857
|
+
return
|
|
858
|
+
now = self._clock().astimezone(UTC)
|
|
859
|
+
updated_descriptor = replace(handle.descriptor, last_used_at=now)
|
|
860
|
+
self._workspace_handle = _WorkspaceHandle(
|
|
861
|
+
descriptor=updated_descriptor,
|
|
862
|
+
overlay_path=handle.overlay_path,
|
|
863
|
+
)
|
|
864
|
+
self._session.seed_slice(PodmanWorkspace, (updated_descriptor,))
|
|
865
|
+
|
|
866
|
+
def _teardown_workspace(self) -> None:
|
|
867
|
+
with self._lock:
|
|
868
|
+
handle = self._workspace_handle
|
|
869
|
+
self._workspace_handle = None
|
|
870
|
+
if handle is None:
|
|
871
|
+
return
|
|
872
|
+
try:
|
|
873
|
+
client = self._client_factory()
|
|
874
|
+
except Exception:
|
|
875
|
+
return
|
|
876
|
+
try:
|
|
877
|
+
try:
|
|
878
|
+
container = client.containers.get(handle.descriptor.container_id)
|
|
879
|
+
except Exception:
|
|
880
|
+
return
|
|
881
|
+
with suppress(Exception):
|
|
882
|
+
container.stop(timeout=1)
|
|
883
|
+
with suppress(Exception):
|
|
884
|
+
container.remove(force=True)
|
|
885
|
+
finally:
|
|
886
|
+
with suppress(Exception):
|
|
887
|
+
client.close()
|
|
888
|
+
|
|
889
|
+
def ensure_workspace(self) -> _WorkspaceHandle:
|
|
890
|
+
return self._ensure_workspace()
|
|
891
|
+
|
|
892
|
+
def workspace_environment(self) -> dict[str, str]:
|
|
893
|
+
return self._workspace_env()
|
|
894
|
+
|
|
895
|
+
def touch_workspace(self) -> None:
|
|
896
|
+
self._touch_workspace()
|
|
897
|
+
|
|
898
|
+
def run_cli_exec(
|
|
899
|
+
self,
|
|
900
|
+
*,
|
|
901
|
+
command: Sequence[str],
|
|
902
|
+
stdin: str | None = None,
|
|
903
|
+
cwd: str | None = None,
|
|
904
|
+
environment: Mapping[str, str] | None = None,
|
|
905
|
+
timeout: float | None = None,
|
|
906
|
+
capture_output: bool = True,
|
|
907
|
+
) -> subprocess.CompletedProcess[str]:
|
|
908
|
+
handle = self.ensure_workspace()
|
|
909
|
+
env = self.workspace_environment()
|
|
910
|
+
if environment:
|
|
911
|
+
env.update(environment)
|
|
912
|
+
|
|
913
|
+
exec_cmd: list[str] = ["podman"]
|
|
914
|
+
if self._connection_name:
|
|
915
|
+
exec_cmd.extend(["--connection", self._connection_name])
|
|
916
|
+
exec_cmd.append("exec")
|
|
917
|
+
if stdin is not None:
|
|
918
|
+
exec_cmd.append("--interactive")
|
|
919
|
+
exec_cmd.extend(["--workdir", cwd or _DEFAULT_WORKDIR])
|
|
920
|
+
for key, value in env.items():
|
|
921
|
+
exec_cmd.extend(["--env", f"{key}={value}"])
|
|
922
|
+
exec_cmd.append(handle.descriptor.container_name)
|
|
923
|
+
exec_cmd.extend(command)
|
|
924
|
+
runner = self._exec_runner
|
|
925
|
+
return runner(
|
|
926
|
+
exec_cmd,
|
|
927
|
+
input=stdin,
|
|
928
|
+
text=True,
|
|
929
|
+
capture_output=capture_output,
|
|
930
|
+
timeout=timeout,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
def run_cli_cp(
|
|
934
|
+
self,
|
|
935
|
+
*,
|
|
936
|
+
source: str,
|
|
937
|
+
destination: str,
|
|
938
|
+
timeout: float | None = None,
|
|
939
|
+
) -> subprocess.CompletedProcess[str]:
|
|
940
|
+
cmd: list[str] = ["podman"]
|
|
941
|
+
if self._connection_name:
|
|
942
|
+
cmd.extend(["--connection", self._connection_name])
|
|
943
|
+
cmd.extend(["cp", source, destination])
|
|
944
|
+
runner = self._exec_runner
|
|
945
|
+
return runner(
|
|
946
|
+
cmd,
|
|
947
|
+
text=True,
|
|
948
|
+
capture_output=True,
|
|
949
|
+
timeout=timeout,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
def run_python_script(
|
|
953
|
+
self,
|
|
954
|
+
*,
|
|
955
|
+
script: str,
|
|
956
|
+
args: Sequence[str],
|
|
957
|
+
timeout: float | None = None,
|
|
958
|
+
) -> subprocess.CompletedProcess[str]:
|
|
959
|
+
return self.run_cli_exec(
|
|
960
|
+
command=["python3", "-c", script, *args],
|
|
961
|
+
timeout=timeout,
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
def write_via_container(
|
|
965
|
+
self,
|
|
966
|
+
*,
|
|
967
|
+
path: VfsPath,
|
|
968
|
+
content: str,
|
|
969
|
+
mode: str,
|
|
970
|
+
) -> None:
|
|
971
|
+
handle = self.ensure_workspace()
|
|
972
|
+
host_path = _host_path_for(handle.overlay_path, path)
|
|
973
|
+
payload = content
|
|
974
|
+
if mode == "append" and host_path.exists():
|
|
975
|
+
try:
|
|
976
|
+
existing = host_path.read_text(encoding="utf-8")
|
|
977
|
+
except UnicodeDecodeError as error:
|
|
978
|
+
raise ToolValidationError("File is not valid UTF-8.") from error
|
|
979
|
+
except OSError as error:
|
|
980
|
+
raise ToolValidationError("Failed to read existing file.") from error
|
|
981
|
+
payload = f"{existing}{content}"
|
|
982
|
+
container_path = _container_path_for(path)
|
|
983
|
+
parent = posixpath.dirname(container_path)
|
|
984
|
+
if parent and parent != "/":
|
|
985
|
+
mkdir_result = self.run_cli_exec(
|
|
986
|
+
command=["mkdir", "-p", parent],
|
|
987
|
+
cwd="/",
|
|
988
|
+
)
|
|
989
|
+
if mkdir_result.returncode != 0:
|
|
990
|
+
message = (
|
|
991
|
+
mkdir_result.stderr.strip()
|
|
992
|
+
or mkdir_result.stdout.strip()
|
|
993
|
+
or "Failed to create target directory."
|
|
994
|
+
)
|
|
995
|
+
raise ToolValidationError(message)
|
|
996
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp:
|
|
997
|
+
_ = tmp.write(payload)
|
|
998
|
+
temp_path = tmp.name
|
|
999
|
+
destination = f"{handle.descriptor.container_name}:{container_path}"
|
|
1000
|
+
try:
|
|
1001
|
+
completed = self.run_cli_cp(
|
|
1002
|
+
source=temp_path,
|
|
1003
|
+
destination=destination,
|
|
1004
|
+
)
|
|
1005
|
+
except FileNotFoundError as error:
|
|
1006
|
+
raise ToolValidationError(
|
|
1007
|
+
"Podman CLI is required to execute filesystem commands."
|
|
1008
|
+
) from error
|
|
1009
|
+
finally:
|
|
1010
|
+
with suppress(OSError):
|
|
1011
|
+
Path(temp_path).unlink()
|
|
1012
|
+
if completed.returncode != 0:
|
|
1013
|
+
message = (
|
|
1014
|
+
completed.stderr.strip() or completed.stdout.strip() or "Write failed."
|
|
1015
|
+
)
|
|
1016
|
+
raise ToolValidationError(message)
|
|
1017
|
+
|
|
1018
|
+
def new_client(self) -> _PodmanClient:
|
|
1019
|
+
return self._client_factory()
|
|
1020
|
+
|
|
1021
|
+
@property
|
|
1022
|
+
def connection_name(self) -> str | None:
|
|
1023
|
+
return self._connection_name
|
|
1024
|
+
|
|
1025
|
+
@property
|
|
1026
|
+
def exec_runner(self) -> _ExecRunner:
|
|
1027
|
+
return self._exec_runner
|
|
1028
|
+
|
|
1029
|
+
def close(self) -> None:
|
|
1030
|
+
finalizer = self._finalizer
|
|
1031
|
+
if finalizer.alive:
|
|
1032
|
+
_ = finalizer()
|
|
1033
|
+
|
|
1034
|
+
@staticmethod
|
|
1035
|
+
def _cleanup_from_finalizer(
|
|
1036
|
+
section_ref: weakref.ReferenceType[PodmanSandboxSection],
|
|
1037
|
+
) -> None:
|
|
1038
|
+
section = section_ref()
|
|
1039
|
+
if section is not None:
|
|
1040
|
+
section._teardown_workspace()
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _host_path_for(root: Path, path: VfsPath) -> Path:
|
|
1044
|
+
host = root
|
|
1045
|
+
for segment in path.segments:
|
|
1046
|
+
host = host / segment
|
|
1047
|
+
return host
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _container_path_for(path: VfsPath) -> str:
|
|
1051
|
+
if not path.segments:
|
|
1052
|
+
return _DEFAULT_WORKDIR
|
|
1053
|
+
return posixpath.join(_DEFAULT_WORKDIR, *path.segments)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _assert_within_overlay(root: Path, candidate: Path) -> None:
|
|
1057
|
+
try:
|
|
1058
|
+
resolved = candidate.resolve()
|
|
1059
|
+
except FileNotFoundError:
|
|
1060
|
+
try:
|
|
1061
|
+
resolved = candidate.parent.resolve()
|
|
1062
|
+
except FileNotFoundError as error: # pragma: no cover - defensive guard
|
|
1063
|
+
raise ToolValidationError("Workspace path is unavailable.") from error
|
|
1064
|
+
try:
|
|
1065
|
+
_ = resolved.relative_to(root)
|
|
1066
|
+
except ValueError as error:
|
|
1067
|
+
raise ToolValidationError("Path escapes the workspace boundary.") from error
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _compose_child_path(base: VfsPath, name: str) -> VfsPath | None:
|
|
1071
|
+
candidate = VfsPath((*base.segments, name))
|
|
1072
|
+
try:
|
|
1073
|
+
return vfs_module.normalize_path(candidate)
|
|
1074
|
+
except ToolValidationError:
|
|
1075
|
+
return None
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _compose_relative_path(base: VfsPath, relative: Path) -> VfsPath | None:
|
|
1079
|
+
segments = (*base.segments, *relative.parts)
|
|
1080
|
+
candidate = VfsPath(segments)
|
|
1081
|
+
try:
|
|
1082
|
+
return vfs_module.normalize_path(candidate)
|
|
1083
|
+
except ToolValidationError:
|
|
1084
|
+
return None
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _iter_workspace_files(base: Path) -> Iterator[Path]:
|
|
1088
|
+
if not base.exists():
|
|
1089
|
+
return
|
|
1090
|
+
for dirpath, _, filenames in os.walk(base, followlinks=False):
|
|
1091
|
+
current = Path(dirpath)
|
|
1092
|
+
for name in filenames:
|
|
1093
|
+
yield current / name
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _stat_file(path: Path) -> tuple[int, datetime]:
|
|
1097
|
+
try:
|
|
1098
|
+
stat_result = path.stat()
|
|
1099
|
+
except OSError as error: # pragma: no cover - defensive guard
|
|
1100
|
+
raise ToolValidationError("Failed to stat workspace file.") from error
|
|
1101
|
+
size = stat_result.st_size
|
|
1102
|
+
updated_at = datetime.fromtimestamp(stat_result.st_mtime, tz=UTC)
|
|
1103
|
+
return size, updated_at
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _format_remove_message(path: VfsPath, count: int) -> str:
|
|
1107
|
+
path_label = "/".join(path.segments) or "/"
|
|
1108
|
+
label = "entry" if count == 1 else "entries"
|
|
1109
|
+
return f"Deleted {count} {label} under {path_label}."
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _format_read_message(path: VfsPath, start: int, end: int) -> str:
|
|
1113
|
+
path_label = "/".join(path.segments) or "/"
|
|
1114
|
+
if start == end:
|
|
1115
|
+
return f"Read file {path_label} (no lines returned)."
|
|
1116
|
+
return f"Read file {path_label} (lines {start + 1}-{end})."
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
class _PodmanVfsSuite:
|
|
1120
|
+
"""Filesystem tool handlers bound to a :class:`PodmanSandboxSection`."""
|
|
1121
|
+
|
|
1122
|
+
def __init__(self, *, section: PodmanSandboxSection) -> None:
|
|
1123
|
+
super().__init__()
|
|
1124
|
+
self._section = section
|
|
1125
|
+
|
|
1126
|
+
def list_directory(
|
|
1127
|
+
self, params: ListDirectoryParams, *, context: ToolContext
|
|
1128
|
+
) -> ToolResult[tuple[FileInfo, ...]]:
|
|
1129
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1130
|
+
del context
|
|
1131
|
+
path = vfs_module.normalize_string_path(
|
|
1132
|
+
params.path, allow_empty=True, field="path"
|
|
1133
|
+
)
|
|
1134
|
+
handle = self._section.ensure_workspace()
|
|
1135
|
+
host_path = _host_path_for(handle.overlay_path, path)
|
|
1136
|
+
_assert_within_overlay(handle.overlay_path, host_path)
|
|
1137
|
+
if host_path.exists() and host_path.is_file():
|
|
1138
|
+
raise ToolValidationError("Cannot list a file path; provide a directory.")
|
|
1139
|
+
snapshot = self._section.latest_snapshot()
|
|
1140
|
+
entries = self._build_directory_entries(
|
|
1141
|
+
base=path,
|
|
1142
|
+
host_path=host_path,
|
|
1143
|
+
snapshot=snapshot,
|
|
1144
|
+
overlay_root=handle.overlay_path,
|
|
1145
|
+
)
|
|
1146
|
+
message = vfs_module.format_directory_message(path, entries)
|
|
1147
|
+
self._section.touch_workspace()
|
|
1148
|
+
return ToolResult(message=message, value=tuple(entries))
|
|
1149
|
+
|
|
1150
|
+
def read_file(
|
|
1151
|
+
self, params: ReadFileParams, *, context: ToolContext
|
|
1152
|
+
) -> ToolResult[ReadFileResult]:
|
|
1153
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1154
|
+
del context
|
|
1155
|
+
path = vfs_module.normalize_string_path(params.file_path, field="file_path")
|
|
1156
|
+
offset = vfs_module.normalize_offset(params.offset)
|
|
1157
|
+
limit = vfs_module.normalize_limit(params.limit)
|
|
1158
|
+
handle = self._section.ensure_workspace()
|
|
1159
|
+
host_path = _host_path_for(handle.overlay_path, path)
|
|
1160
|
+
if not host_path.exists() or not host_path.is_file():
|
|
1161
|
+
raise ToolValidationError("File does not exist in the workspace.")
|
|
1162
|
+
_assert_within_overlay(handle.overlay_path, host_path)
|
|
1163
|
+
try:
|
|
1164
|
+
content = host_path.read_text(encoding="utf-8")
|
|
1165
|
+
except UnicodeDecodeError as error:
|
|
1166
|
+
raise ToolValidationError("File is not valid UTF-8.") from error
|
|
1167
|
+
except OSError as error: # pragma: no cover - defensive guard
|
|
1168
|
+
raise ToolValidationError("Failed to read file contents.") from error
|
|
1169
|
+
lines = content.splitlines()
|
|
1170
|
+
total_lines = len(lines)
|
|
1171
|
+
start = min(offset, total_lines)
|
|
1172
|
+
end = min(start + limit, total_lines)
|
|
1173
|
+
numbered = [
|
|
1174
|
+
f"{index + 1:>4} | {line}"
|
|
1175
|
+
for index, line in enumerate(lines[start:end], start=start)
|
|
1176
|
+
]
|
|
1177
|
+
formatted = "\n".join(numbered)
|
|
1178
|
+
message = _format_read_message(path, start, end)
|
|
1179
|
+
self._section.touch_workspace()
|
|
1180
|
+
return ToolResult(
|
|
1181
|
+
message=message,
|
|
1182
|
+
value=ReadFileResult(
|
|
1183
|
+
path=path,
|
|
1184
|
+
content=formatted,
|
|
1185
|
+
offset=start,
|
|
1186
|
+
limit=end - start,
|
|
1187
|
+
total_lines=total_lines,
|
|
1188
|
+
),
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
def write_file(
|
|
1192
|
+
self, params: WriteFileParams, *, context: ToolContext
|
|
1193
|
+
) -> ToolResult[WriteFile]:
|
|
1194
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1195
|
+
del context
|
|
1196
|
+
path = vfs_module.normalize_string_path(params.file_path, field="file_path")
|
|
1197
|
+
content = vfs_module.normalize_content(params.content)
|
|
1198
|
+
handle = self._section.ensure_workspace()
|
|
1199
|
+
host_path = _host_path_for(handle.overlay_path, path)
|
|
1200
|
+
if host_path.exists():
|
|
1201
|
+
raise ToolValidationError(
|
|
1202
|
+
"File already exists; use edit_file to modify existing content."
|
|
1203
|
+
)
|
|
1204
|
+
_assert_within_overlay(handle.overlay_path, host_path)
|
|
1205
|
+
self._section.write_via_container(path=path, content=content, mode="create")
|
|
1206
|
+
self._section.touch_workspace()
|
|
1207
|
+
message = vfs_module.format_write_file_message(path, content, "create")
|
|
1208
|
+
return ToolResult(
|
|
1209
|
+
message=message,
|
|
1210
|
+
value=WriteFile(path=path, content=content, mode="create"),
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
def edit_file(
|
|
1214
|
+
self, params: EditFileParams, *, context: ToolContext
|
|
1215
|
+
) -> ToolResult[WriteFile]:
|
|
1216
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1217
|
+
del context
|
|
1218
|
+
path = vfs_module.normalize_string_path(params.file_path, field="file_path")
|
|
1219
|
+
if len(params.old_string) > vfs_module.MAX_WRITE_LENGTH:
|
|
1220
|
+
raise ToolValidationError("old_string exceeds the 48,000 character limit.")
|
|
1221
|
+
if len(params.new_string) > vfs_module.MAX_WRITE_LENGTH:
|
|
1222
|
+
raise ToolValidationError("new_string exceeds the 48,000 character limit.")
|
|
1223
|
+
handle = self._section.ensure_workspace()
|
|
1224
|
+
host_path = _host_path_for(handle.overlay_path, path)
|
|
1225
|
+
if not host_path.exists() or not host_path.is_file():
|
|
1226
|
+
raise ToolValidationError("File does not exist in the workspace.")
|
|
1227
|
+
_assert_within_overlay(handle.overlay_path, host_path)
|
|
1228
|
+
try:
|
|
1229
|
+
existing = host_path.read_text(encoding="utf-8")
|
|
1230
|
+
except UnicodeDecodeError as error:
|
|
1231
|
+
raise ToolValidationError("File is not valid UTF-8.") from error
|
|
1232
|
+
occurrences = existing.count(params.old_string)
|
|
1233
|
+
if occurrences == 0:
|
|
1234
|
+
raise ToolValidationError("old_string not found in the target file.")
|
|
1235
|
+
if not params.replace_all and occurrences != 1:
|
|
1236
|
+
raise ToolValidationError(
|
|
1237
|
+
"old_string must match exactly once unless replace_all is true."
|
|
1238
|
+
)
|
|
1239
|
+
if params.replace_all:
|
|
1240
|
+
replacements = occurrences
|
|
1241
|
+
updated = existing.replace(params.old_string, params.new_string)
|
|
1242
|
+
else:
|
|
1243
|
+
replacements = 1
|
|
1244
|
+
updated = existing.replace(params.old_string, params.new_string, 1)
|
|
1245
|
+
normalized = vfs_module.normalize_content(updated)
|
|
1246
|
+
self._section.write_via_container(
|
|
1247
|
+
path=path, content=normalized, mode="overwrite"
|
|
1248
|
+
)
|
|
1249
|
+
self._section.touch_workspace()
|
|
1250
|
+
message = vfs_module.format_edit_message(path, replacements)
|
|
1251
|
+
return ToolResult(
|
|
1252
|
+
message=message,
|
|
1253
|
+
value=WriteFile(path=path, content=normalized, mode="overwrite"),
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
def glob(
|
|
1257
|
+
self, params: GlobParams, *, context: ToolContext
|
|
1258
|
+
) -> ToolResult[tuple[GlobMatch, ...]]:
|
|
1259
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1260
|
+
del context
|
|
1261
|
+
base = vfs_module.normalize_string_path(
|
|
1262
|
+
params.path, allow_empty=True, field="path"
|
|
1263
|
+
)
|
|
1264
|
+
pattern = params.pattern.strip()
|
|
1265
|
+
if not pattern:
|
|
1266
|
+
raise ToolValidationError("Pattern must not be empty.")
|
|
1267
|
+
_ = vfs_module.ensure_ascii(pattern, "pattern")
|
|
1268
|
+
handle = self._section.ensure_workspace()
|
|
1269
|
+
host_base = _host_path_for(handle.overlay_path, base)
|
|
1270
|
+
_assert_within_overlay(handle.overlay_path, host_base)
|
|
1271
|
+
matches: list[GlobMatch] = []
|
|
1272
|
+
snapshot = self._section.latest_snapshot()
|
|
1273
|
+
for file_path in _iter_workspace_files(host_base):
|
|
1274
|
+
try:
|
|
1275
|
+
relative = file_path.relative_to(host_base)
|
|
1276
|
+
except ValueError:
|
|
1277
|
+
continue
|
|
1278
|
+
candidate_path = _compose_relative_path(base, relative)
|
|
1279
|
+
if candidate_path is None:
|
|
1280
|
+
continue
|
|
1281
|
+
relative_label = relative.as_posix()
|
|
1282
|
+
if not fnmatch.fnmatchcase(relative_label, pattern):
|
|
1283
|
+
continue
|
|
1284
|
+
try:
|
|
1285
|
+
match = self._build_glob_match(
|
|
1286
|
+
target=candidate_path,
|
|
1287
|
+
host_path=file_path,
|
|
1288
|
+
snapshot=snapshot,
|
|
1289
|
+
overlay_root=handle.overlay_path,
|
|
1290
|
+
)
|
|
1291
|
+
except ToolValidationError:
|
|
1292
|
+
continue
|
|
1293
|
+
matches.append(match)
|
|
1294
|
+
if len(matches) >= _MAX_MATCH_RESULTS:
|
|
1295
|
+
break
|
|
1296
|
+
matches.sort(key=lambda match: match.path.segments)
|
|
1297
|
+
message = vfs_module.format_glob_message(base, pattern, matches)
|
|
1298
|
+
self._section.touch_workspace()
|
|
1299
|
+
return ToolResult(message=message, value=tuple(matches))
|
|
1300
|
+
|
|
1301
|
+
def grep(
|
|
1302
|
+
self, params: GrepParams, *, context: ToolContext
|
|
1303
|
+
) -> ToolResult[tuple[GrepMatch, ...]]:
|
|
1304
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1305
|
+
del context
|
|
1306
|
+
try:
|
|
1307
|
+
pattern = re.compile(params.pattern)
|
|
1308
|
+
except re.error as error:
|
|
1309
|
+
return ToolResult(
|
|
1310
|
+
message=f"Invalid regular expression: {error}",
|
|
1311
|
+
value=None,
|
|
1312
|
+
success=False,
|
|
1313
|
+
)
|
|
1314
|
+
base_path: VfsPath | None = None
|
|
1315
|
+
if params.path is not None:
|
|
1316
|
+
base_path = vfs_module.normalize_string_path(
|
|
1317
|
+
params.path, allow_empty=True, field="path"
|
|
1318
|
+
)
|
|
1319
|
+
glob_pattern = params.glob.strip() if params.glob is not None else None
|
|
1320
|
+
if glob_pattern:
|
|
1321
|
+
_ = vfs_module.ensure_ascii(glob_pattern, "glob")
|
|
1322
|
+
handle = self._section.ensure_workspace()
|
|
1323
|
+
host_base = _host_path_for(handle.overlay_path, base_path or VfsPath(()))
|
|
1324
|
+
_assert_within_overlay(handle.overlay_path, host_base)
|
|
1325
|
+
matches: list[GrepMatch] = []
|
|
1326
|
+
for file_path in _iter_workspace_files(host_base):
|
|
1327
|
+
try:
|
|
1328
|
+
relative = file_path.relative_to(host_base)
|
|
1329
|
+
except ValueError:
|
|
1330
|
+
continue
|
|
1331
|
+
relative_label = relative.as_posix()
|
|
1332
|
+
if glob_pattern and not fnmatch.fnmatchcase(relative_label, glob_pattern):
|
|
1333
|
+
continue
|
|
1334
|
+
target_path = _compose_relative_path(base_path or VfsPath(()), relative)
|
|
1335
|
+
if target_path is None:
|
|
1336
|
+
continue
|
|
1337
|
+
try:
|
|
1338
|
+
_assert_within_overlay(handle.overlay_path, file_path)
|
|
1339
|
+
except ToolValidationError:
|
|
1340
|
+
continue
|
|
1341
|
+
try:
|
|
1342
|
+
content = file_path.read_text(encoding="utf-8")
|
|
1343
|
+
except UnicodeDecodeError:
|
|
1344
|
+
continue
|
|
1345
|
+
except OSError:
|
|
1346
|
+
continue
|
|
1347
|
+
for index, line in enumerate(content.splitlines(), start=1):
|
|
1348
|
+
if pattern.search(line):
|
|
1349
|
+
matches.append(
|
|
1350
|
+
GrepMatch(
|
|
1351
|
+
path=target_path,
|
|
1352
|
+
line_number=index,
|
|
1353
|
+
line=line,
|
|
1354
|
+
)
|
|
1355
|
+
)
|
|
1356
|
+
if len(matches) >= _MAX_MATCH_RESULTS:
|
|
1357
|
+
break
|
|
1358
|
+
if len(matches) >= _MAX_MATCH_RESULTS:
|
|
1359
|
+
break
|
|
1360
|
+
message = vfs_module.format_grep_message(params.pattern, matches)
|
|
1361
|
+
self._section.touch_workspace()
|
|
1362
|
+
return ToolResult(message=message, value=tuple(matches))
|
|
1363
|
+
|
|
1364
|
+
def remove(
|
|
1365
|
+
self, params: RemoveParams, *, context: ToolContext
|
|
1366
|
+
) -> ToolResult[DeleteEntry]:
|
|
1367
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1368
|
+
del context
|
|
1369
|
+
path = vfs_module.normalize_string_path(params.path, field="path")
|
|
1370
|
+
if not path.segments:
|
|
1371
|
+
raise ToolValidationError("Cannot remove the workspace root.")
|
|
1372
|
+
handle = self._section.ensure_workspace()
|
|
1373
|
+
host_path = _host_path_for(handle.overlay_path, path)
|
|
1374
|
+
if not host_path.exists():
|
|
1375
|
+
raise ToolValidationError("No files matched the provided path.")
|
|
1376
|
+
_assert_within_overlay(handle.overlay_path, host_path)
|
|
1377
|
+
removed_entries = sum(1 for _ in _iter_workspace_files(host_path))
|
|
1378
|
+
removed_entries = 1 if host_path.is_file() else max(removed_entries, 1)
|
|
1379
|
+
args = (_container_path_for(path),)
|
|
1380
|
+
try:
|
|
1381
|
+
completed = self._section.run_python_script(
|
|
1382
|
+
script=_REMOVE_PATH_SCRIPT,
|
|
1383
|
+
args=args,
|
|
1384
|
+
)
|
|
1385
|
+
except FileNotFoundError as error:
|
|
1386
|
+
raise ToolValidationError(
|
|
1387
|
+
"Podman CLI is required to execute filesystem commands."
|
|
1388
|
+
) from error
|
|
1389
|
+
if completed.returncode != 0:
|
|
1390
|
+
message = (
|
|
1391
|
+
completed.stderr.strip()
|
|
1392
|
+
or completed.stdout.strip()
|
|
1393
|
+
or "Removal failed."
|
|
1394
|
+
)
|
|
1395
|
+
raise ToolValidationError(message)
|
|
1396
|
+
self._section.touch_workspace()
|
|
1397
|
+
message = _format_remove_message(path, removed_entries)
|
|
1398
|
+
return ToolResult(
|
|
1399
|
+
message=message,
|
|
1400
|
+
value=DeleteEntry(path=path),
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
def _build_directory_entries(
|
|
1404
|
+
self,
|
|
1405
|
+
*,
|
|
1406
|
+
base: VfsPath,
|
|
1407
|
+
host_path: Path,
|
|
1408
|
+
snapshot: VirtualFileSystem,
|
|
1409
|
+
overlay_root: Path,
|
|
1410
|
+
) -> list[FileInfo]:
|
|
1411
|
+
entries: list[FileInfo] = []
|
|
1412
|
+
if not host_path.exists():
|
|
1413
|
+
return entries
|
|
1414
|
+
try:
|
|
1415
|
+
children = sorted(host_path.iterdir(), key=lambda child: child.name.lower())
|
|
1416
|
+
except OSError as error:
|
|
1417
|
+
raise ToolValidationError(
|
|
1418
|
+
"Failed to inspect directory contents."
|
|
1419
|
+
) from error
|
|
1420
|
+
for child in children:
|
|
1421
|
+
entry_path = _compose_child_path(base, child.name)
|
|
1422
|
+
if entry_path is None:
|
|
1423
|
+
continue
|
|
1424
|
+
if child.is_dir() and not child.is_symlink():
|
|
1425
|
+
entries.append(
|
|
1426
|
+
FileInfo(
|
|
1427
|
+
path=entry_path,
|
|
1428
|
+
kind="directory",
|
|
1429
|
+
size_bytes=None,
|
|
1430
|
+
version=None,
|
|
1431
|
+
updated_at=None,
|
|
1432
|
+
)
|
|
1433
|
+
)
|
|
1434
|
+
continue
|
|
1435
|
+
try:
|
|
1436
|
+
info = self._build_file_info(
|
|
1437
|
+
path=entry_path,
|
|
1438
|
+
host_file=child,
|
|
1439
|
+
snapshot=snapshot,
|
|
1440
|
+
overlay_root=overlay_root,
|
|
1441
|
+
)
|
|
1442
|
+
except ToolValidationError:
|
|
1443
|
+
continue
|
|
1444
|
+
entries.append(info)
|
|
1445
|
+
entries.sort(key=lambda entry: entry.path.segments)
|
|
1446
|
+
return entries[:_MAX_MATCH_RESULTS]
|
|
1447
|
+
|
|
1448
|
+
def _build_file_info(
|
|
1449
|
+
self,
|
|
1450
|
+
*,
|
|
1451
|
+
path: VfsPath,
|
|
1452
|
+
host_file: Path,
|
|
1453
|
+
snapshot: VirtualFileSystem,
|
|
1454
|
+
overlay_root: Path,
|
|
1455
|
+
) -> FileInfo:
|
|
1456
|
+
_assert_within_overlay(overlay_root, host_file)
|
|
1457
|
+
snapshot_entry = vfs_module.find_file(snapshot.files, path)
|
|
1458
|
+
size_bytes, updated_at = _stat_file(host_file)
|
|
1459
|
+
version = snapshot_entry.version if snapshot_entry else None
|
|
1460
|
+
updated = snapshot_entry.updated_at if snapshot_entry else updated_at
|
|
1461
|
+
return FileInfo(
|
|
1462
|
+
path=path,
|
|
1463
|
+
kind="file",
|
|
1464
|
+
size_bytes=size_bytes,
|
|
1465
|
+
version=version,
|
|
1466
|
+
updated_at=updated,
|
|
1467
|
+
)
|
|
1468
|
+
|
|
1469
|
+
def _build_glob_match(
|
|
1470
|
+
self,
|
|
1471
|
+
*,
|
|
1472
|
+
target: VfsPath,
|
|
1473
|
+
host_path: Path,
|
|
1474
|
+
snapshot: VirtualFileSystem,
|
|
1475
|
+
overlay_root: Path,
|
|
1476
|
+
) -> GlobMatch:
|
|
1477
|
+
_assert_within_overlay(overlay_root, host_path)
|
|
1478
|
+
snapshot_entry = vfs_module.find_file(snapshot.files, target)
|
|
1479
|
+
size_bytes, updated_at = _stat_file(host_path)
|
|
1480
|
+
if snapshot_entry is None:
|
|
1481
|
+
return GlobMatch(
|
|
1482
|
+
path=target,
|
|
1483
|
+
size_bytes=size_bytes,
|
|
1484
|
+
version=1,
|
|
1485
|
+
updated_at=updated_at,
|
|
1486
|
+
)
|
|
1487
|
+
return GlobMatch(
|
|
1488
|
+
path=target,
|
|
1489
|
+
size_bytes=size_bytes,
|
|
1490
|
+
version=snapshot_entry.version,
|
|
1491
|
+
updated_at=snapshot_entry.updated_at,
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
class _PodmanEvalSuite:
|
|
1496
|
+
def __init__(self, *, section: PodmanSandboxSection) -> None:
|
|
1497
|
+
super().__init__()
|
|
1498
|
+
self._section = section
|
|
1499
|
+
|
|
1500
|
+
def evaluate_python(
|
|
1501
|
+
self, params: EvalParams, *, context: ToolContext
|
|
1502
|
+
) -> ToolResult[EvalResult]:
|
|
1503
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1504
|
+
del context
|
|
1505
|
+
self._ensure_passthrough_payload_is_empty(params)
|
|
1506
|
+
code = _normalize_podman_eval_code(params.code)
|
|
1507
|
+
_ = self._section.ensure_workspace()
|
|
1508
|
+
try:
|
|
1509
|
+
completed = self._section.run_python_script(
|
|
1510
|
+
script=code,
|
|
1511
|
+
args=(),
|
|
1512
|
+
timeout=_EVAL_TIMEOUT_SECONDS,
|
|
1513
|
+
)
|
|
1514
|
+
except subprocess.TimeoutExpired:
|
|
1515
|
+
return self._timeout_result()
|
|
1516
|
+
except FileNotFoundError as error:
|
|
1517
|
+
raise ToolValidationError(
|
|
1518
|
+
"Podman CLI is required to execute evaluation commands."
|
|
1519
|
+
) from error
|
|
1520
|
+
|
|
1521
|
+
stdout = _truncate_eval_stream(str(completed.stdout or ""))
|
|
1522
|
+
stderr = _truncate_eval_stream(str(completed.stderr or ""))
|
|
1523
|
+
success = completed.returncode == 0
|
|
1524
|
+
if success:
|
|
1525
|
+
message = f"Evaluation succeeded (exit code {completed.returncode})."
|
|
1526
|
+
else:
|
|
1527
|
+
message = f"Evaluation failed (exit code {completed.returncode})."
|
|
1528
|
+
result = EvalResult(
|
|
1529
|
+
value_repr=None,
|
|
1530
|
+
stdout=stdout,
|
|
1531
|
+
stderr=stderr,
|
|
1532
|
+
globals={},
|
|
1533
|
+
reads=(),
|
|
1534
|
+
writes=(),
|
|
1535
|
+
)
|
|
1536
|
+
self._section.touch_workspace()
|
|
1537
|
+
return ToolResult(message=message, value=result, success=success)
|
|
1538
|
+
|
|
1539
|
+
def _ensure_passthrough_payload_is_empty(self, params: EvalParams) -> None:
|
|
1540
|
+
if params.reads:
|
|
1541
|
+
raise ToolValidationError(
|
|
1542
|
+
"Podman evaluate_python reads are not supported; access the workspace directly."
|
|
1543
|
+
)
|
|
1544
|
+
if params.writes:
|
|
1545
|
+
raise ToolValidationError(
|
|
1546
|
+
"Podman evaluate_python writes are not supported; use the write_file tool."
|
|
1547
|
+
)
|
|
1548
|
+
if params.globals:
|
|
1549
|
+
raise ToolValidationError(
|
|
1550
|
+
"Podman evaluate_python globals are not supported."
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
def _timeout_result(self) -> ToolResult[EvalResult]:
|
|
1554
|
+
result = EvalResult(
|
|
1555
|
+
value_repr=None,
|
|
1556
|
+
stdout="",
|
|
1557
|
+
stderr="Execution timed out.",
|
|
1558
|
+
globals={},
|
|
1559
|
+
reads=(),
|
|
1560
|
+
writes=(),
|
|
1561
|
+
)
|
|
1562
|
+
return ToolResult(message="Evaluation timed out.", value=result, success=False)
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
class _PodmanShellSuite:
|
|
1566
|
+
"""Handler collection bound to a :class:`PodmanSandboxSection`."""
|
|
1567
|
+
|
|
1568
|
+
def __init__(self, *, section: PodmanSandboxSection) -> None:
|
|
1569
|
+
super().__init__()
|
|
1570
|
+
self._section = section
|
|
1571
|
+
|
|
1572
|
+
def run_shell(
|
|
1573
|
+
self, params: PodmanShellParams, *, context: ToolContext
|
|
1574
|
+
) -> ToolResult[PodmanShellResult]:
|
|
1575
|
+
ensure_context_uses_session(context=context, session=self._section.session)
|
|
1576
|
+
command = _normalize_command(params.command)
|
|
1577
|
+
cwd = _normalize_cwd(params.cwd)
|
|
1578
|
+
env_overrides = _normalize_env(params.env)
|
|
1579
|
+
timeout_seconds = _normalize_timeout(params.timeout_seconds)
|
|
1580
|
+
if params.stdin:
|
|
1581
|
+
_ = _ensure_ascii(params.stdin, field="stdin")
|
|
1582
|
+
_ = self._section.ensure_workspace()
|
|
1583
|
+
|
|
1584
|
+
return self._run_shell_via_cli(
|
|
1585
|
+
params=params,
|
|
1586
|
+
command=command,
|
|
1587
|
+
cwd=cwd,
|
|
1588
|
+
environment=env_overrides,
|
|
1589
|
+
timeout_seconds=timeout_seconds,
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
def _run_shell_via_cli(
|
|
1593
|
+
self,
|
|
1594
|
+
*,
|
|
1595
|
+
params: PodmanShellParams,
|
|
1596
|
+
command: tuple[str, ...],
|
|
1597
|
+
cwd: str,
|
|
1598
|
+
environment: Mapping[str, str],
|
|
1599
|
+
timeout_seconds: float,
|
|
1600
|
+
) -> ToolResult[PodmanShellResult]:
|
|
1601
|
+
exec_cmd = list(command)
|
|
1602
|
+
start = time.perf_counter()
|
|
1603
|
+
try:
|
|
1604
|
+
completed = self._section.run_cli_exec(
|
|
1605
|
+
command=exec_cmd,
|
|
1606
|
+
stdin=params.stdin if params.stdin else None,
|
|
1607
|
+
cwd=cwd,
|
|
1608
|
+
environment=environment,
|
|
1609
|
+
timeout=timeout_seconds,
|
|
1610
|
+
capture_output=params.capture_output,
|
|
1611
|
+
)
|
|
1612
|
+
timed_out = False
|
|
1613
|
+
exit_code = completed.returncode
|
|
1614
|
+
stdout_text = completed.stdout
|
|
1615
|
+
stderr_text = completed.stderr
|
|
1616
|
+
except subprocess.TimeoutExpired as error:
|
|
1617
|
+
timed_out = True
|
|
1618
|
+
exit_code = 124
|
|
1619
|
+
stdout_text = str(error.stdout or "")
|
|
1620
|
+
stderr_text = str(error.stderr or "")
|
|
1621
|
+
except FileNotFoundError as error:
|
|
1622
|
+
raise ToolValidationError(
|
|
1623
|
+
"Podman CLI is required to execute commands over SSH connections."
|
|
1624
|
+
) from error
|
|
1625
|
+
duration_ms = int((time.perf_counter() - start) * 1_000)
|
|
1626
|
+
self._section.touch_workspace()
|
|
1627
|
+
stdout_text_clean = str(stdout_text or "").rstrip()
|
|
1628
|
+
stderr_text_clean = str(stderr_text or "").rstrip()
|
|
1629
|
+
if not params.capture_output:
|
|
1630
|
+
stdout_text_final = _CAPTURE_DISABLED
|
|
1631
|
+
stderr_text_final = _CAPTURE_DISABLED
|
|
1632
|
+
else:
|
|
1633
|
+
stdout_text_final = _truncate_stream(stdout_text_clean)
|
|
1634
|
+
stderr_text_final = _truncate_stream(stderr_text_clean)
|
|
1635
|
+
result = PodmanShellResult(
|
|
1636
|
+
command=command,
|
|
1637
|
+
cwd=cwd,
|
|
1638
|
+
exit_code=exit_code,
|
|
1639
|
+
stdout=stdout_text_final,
|
|
1640
|
+
stderr=stderr_text_final,
|
|
1641
|
+
duration_ms=duration_ms,
|
|
1642
|
+
timed_out=timed_out,
|
|
1643
|
+
)
|
|
1644
|
+
message = f"`shell_execute` exited with {exit_code}."
|
|
1645
|
+
if timed_out:
|
|
1646
|
+
message = "`shell_execute` exceeded the configured timeout."
|
|
1647
|
+
return ToolResult(message=message, value=result)
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
__all__ = [
|
|
1651
|
+
"PodmanSandboxSection",
|
|
1652
|
+
"PodmanShellParams",
|
|
1653
|
+
"PodmanShellResult",
|
|
1654
|
+
"PodmanWorkspace",
|
|
1655
|
+
]
|