ripperdoc 0.2.9__py3-none-any.whl → 0.2.10__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +235 -14
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +132 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/models_cmd.py +3 -3
- ripperdoc/cli/commands/resume_cmd.py +4 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/rich_ui.py +295 -24
- ripperdoc/cli/ui/spinner.py +30 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/wizard.py +6 -8
- ripperdoc/core/agents.py +10 -3
- ripperdoc/core/config.py +3 -6
- ripperdoc/core/default_tools.py +90 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/permissions.py +78 -4
- ripperdoc/core/providers/openai.py +29 -19
- ripperdoc/core/query.py +192 -31
- ripperdoc/core/tool.py +9 -4
- ripperdoc/sdk/client.py +77 -2
- ripperdoc/tools/background_shell.py +305 -134
- ripperdoc/tools/bash_tool.py +42 -13
- ripperdoc/tools/file_edit_tool.py +159 -50
- ripperdoc/tools/file_read_tool.py +20 -0
- ripperdoc/tools/file_write_tool.py +7 -8
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/task_tool.py +514 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +206 -3
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/message_formatting.py +5 -2
- ripperdoc/utils/messages.py +21 -1
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_stats.py +293 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/RECORD +45 -39
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
|
@@ -8,9 +8,11 @@ via the KillBash tool.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import concurrent.futures
|
|
10
10
|
import contextlib
|
|
11
|
+
import os
|
|
11
12
|
import threading
|
|
12
13
|
import time
|
|
13
14
|
import uuid
|
|
15
|
+
import weakref
|
|
14
16
|
from dataclasses import dataclass, field
|
|
15
17
|
from typing import Any, Dict, List, Optional
|
|
16
18
|
|
|
@@ -41,67 +43,271 @@ class BackgroundTask:
|
|
|
41
43
|
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
42
44
|
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
_tasks_lock = threading.Lock()
|
|
46
|
-
_background_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
47
|
-
_background_thread: Optional[threading.Thread] = None
|
|
48
|
-
_loop_lock = threading.Lock()
|
|
49
|
-
_shutdown_registered = False
|
|
46
|
+
DEFAULT_TASK_TTL_SEC = float(os.getenv("RIPPERDOC_BASH_TASK_TTL_SEC", "3600"))
|
|
50
47
|
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
"""
|
|
54
|
-
try:
|
|
55
|
-
logger.exception(message, extra=extra)
|
|
56
|
-
except (OSError, RuntimeError, ValueError):
|
|
57
|
-
pass
|
|
49
|
+
class BackgroundShellManager:
|
|
50
|
+
"""Manager for background shell tasks with proper lifecycle control.
|
|
58
51
|
|
|
52
|
+
This class encapsulates all global state for background shell management,
|
|
53
|
+
providing better testability and proper resource cleanup.
|
|
54
|
+
"""
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
_instance: Optional["BackgroundShellManager"] = None
|
|
57
|
+
_instance_lock = threading.Lock()
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
"""Initialize the manager. Use get_instance() for singleton access."""
|
|
61
|
+
self._tasks: Dict[str, BackgroundTask] = {}
|
|
62
|
+
self._tasks_lock = threading.Lock()
|
|
63
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
64
|
+
self._thread: Optional[threading.Thread] = None
|
|
65
|
+
self._loop_lock = threading.Lock()
|
|
66
|
+
self._shutdown_event = threading.Event()
|
|
67
|
+
self._shutdown_registered = False
|
|
68
|
+
self._is_shutting_down = False
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def get_instance(cls) -> "BackgroundShellManager":
|
|
72
|
+
"""Get or create the singleton instance."""
|
|
73
|
+
if cls._instance is None:
|
|
74
|
+
with cls._instance_lock:
|
|
75
|
+
if cls._instance is None:
|
|
76
|
+
cls._instance = cls()
|
|
77
|
+
return cls._instance
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def reset_instance(cls) -> None:
|
|
81
|
+
"""Reset the singleton instance. Useful for testing.
|
|
82
|
+
|
|
83
|
+
This method shuts down the current instance and clears it,
|
|
84
|
+
allowing a fresh instance to be created on next access.
|
|
85
|
+
"""
|
|
86
|
+
with cls._instance_lock:
|
|
87
|
+
if cls._instance is not None:
|
|
88
|
+
cls._instance.shutdown()
|
|
89
|
+
cls._instance = None
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def _set_instance_for_testing(cls, instance: Optional["BackgroundShellManager"]) -> None:
|
|
93
|
+
"""Set a custom instance for testing purposes."""
|
|
94
|
+
with cls._instance_lock:
|
|
95
|
+
cls._instance = instance
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def tasks(self) -> Dict[str, BackgroundTask]:
|
|
99
|
+
"""Access to tasks dict (for internal use)."""
|
|
100
|
+
return self._tasks
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def tasks_lock(self) -> threading.Lock:
|
|
104
|
+
"""Access to tasks lock (for internal use)."""
|
|
105
|
+
return self._tasks_lock
|
|
106
|
+
|
|
107
|
+
def ensure_loop(self) -> asyncio.AbstractEventLoop:
|
|
108
|
+
"""Create (or return) a dedicated loop for background processes."""
|
|
109
|
+
if self._loop and self._loop.is_running():
|
|
110
|
+
return self._loop
|
|
111
|
+
|
|
112
|
+
with self._loop_lock:
|
|
113
|
+
if self._loop and self._loop.is_running():
|
|
114
|
+
return self._loop
|
|
115
|
+
|
|
116
|
+
loop = asyncio.new_event_loop()
|
|
117
|
+
ready = threading.Event()
|
|
118
|
+
shutdown_event = self._shutdown_event
|
|
119
|
+
|
|
120
|
+
def _run_loop() -> None:
|
|
121
|
+
asyncio.set_event_loop(loop)
|
|
122
|
+
ready.set()
|
|
123
|
+
try:
|
|
124
|
+
loop.run_forever()
|
|
125
|
+
finally:
|
|
126
|
+
# Ensure cleanup happens even if loop is stopped abruptly
|
|
127
|
+
shutdown_event.set()
|
|
128
|
+
|
|
129
|
+
# Use non-daemon thread to ensure atexit handlers can complete
|
|
130
|
+
thread = threading.Thread(
|
|
131
|
+
target=_run_loop,
|
|
132
|
+
name="ripperdoc-bg-loop",
|
|
133
|
+
daemon=False, # Non-daemon for proper shutdown
|
|
134
|
+
)
|
|
135
|
+
thread.start()
|
|
136
|
+
ready.wait()
|
|
137
|
+
|
|
138
|
+
self._loop = loop
|
|
139
|
+
self._thread = thread
|
|
140
|
+
self._register_shutdown_hook()
|
|
141
|
+
return loop
|
|
142
|
+
|
|
143
|
+
def _register_shutdown_hook(self) -> None:
|
|
144
|
+
"""Register atexit handler for cleanup."""
|
|
145
|
+
if self._shutdown_registered:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# Use weakref to avoid preventing garbage collection
|
|
149
|
+
manager_ref = weakref.ref(self)
|
|
150
|
+
|
|
151
|
+
def _shutdown_callback() -> None:
|
|
152
|
+
manager = manager_ref()
|
|
153
|
+
if manager is not None:
|
|
154
|
+
manager.shutdown()
|
|
155
|
+
|
|
156
|
+
atexit.register(_shutdown_callback)
|
|
157
|
+
self._shutdown_registered = True
|
|
158
|
+
|
|
159
|
+
def submit_to_loop(self, coro: Any) -> concurrent.futures.Future:
|
|
160
|
+
"""Run a coroutine on the background loop and return a thread-safe future."""
|
|
161
|
+
loop = self.ensure_loop()
|
|
162
|
+
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
163
|
+
|
|
164
|
+
def shutdown(self, force: bool = False) -> None:
|
|
165
|
+
"""Stop background tasks/loop to avoid resource leaks.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
force: If True, use minimal timeouts for faster exit.
|
|
169
|
+
"""
|
|
170
|
+
if self._is_shutting_down:
|
|
171
|
+
# If already shutting down and force is requested, just mark done
|
|
172
|
+
if force:
|
|
173
|
+
self._shutdown_event.set()
|
|
174
|
+
return
|
|
175
|
+
self._is_shutting_down = True
|
|
176
|
+
|
|
177
|
+
loop = self._loop
|
|
178
|
+
thread = self._thread
|
|
179
|
+
|
|
180
|
+
if not loop or loop.is_closed():
|
|
181
|
+
self._loop = None
|
|
182
|
+
self._thread = None
|
|
183
|
+
self._is_shutting_down = False
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Use shorter timeouts for faster exit
|
|
187
|
+
async_timeout = 0.5 if force else 2.0
|
|
188
|
+
join_timeout = 0.5 if force else 1.0
|
|
63
189
|
|
|
64
|
-
|
|
65
|
-
|
|
190
|
+
try:
|
|
191
|
+
if loop.is_running():
|
|
192
|
+
try:
|
|
193
|
+
fut = asyncio.run_coroutine_threadsafe(
|
|
194
|
+
self._shutdown_loop_async(loop, force=force), loop
|
|
195
|
+
)
|
|
196
|
+
fut.result(timeout=async_timeout)
|
|
197
|
+
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
198
|
+
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
199
|
+
try:
|
|
200
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
201
|
+
except (RuntimeError, OSError):
|
|
202
|
+
logger.debug("Failed to stop background loop", exc_info=True)
|
|
203
|
+
else:
|
|
204
|
+
# If loop isn't running, try to run cleanup synchronously
|
|
205
|
+
try:
|
|
206
|
+
loop.run_until_complete(self._shutdown_loop_async(loop, force=force))
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
pass # Loop may already be closed
|
|
209
|
+
finally:
|
|
210
|
+
if thread and thread.is_alive():
|
|
211
|
+
thread.join(timeout=join_timeout)
|
|
212
|
+
# If thread is still alive after timeout, don't block further
|
|
213
|
+
if thread.is_alive():
|
|
214
|
+
logger.debug("Background thread did not stop in time, continuing shutdown")
|
|
215
|
+
with contextlib.suppress(Exception):
|
|
216
|
+
if not loop.is_closed():
|
|
217
|
+
loop.close()
|
|
218
|
+
self._loop = None
|
|
219
|
+
self._thread = None
|
|
220
|
+
self._shutdown_event.set()
|
|
221
|
+
self._is_shutting_down = False
|
|
222
|
+
|
|
223
|
+
async def _shutdown_loop_async(
|
|
224
|
+
self, loop: asyncio.AbstractEventLoop, force: bool = False
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Drain running background processes before stopping the loop.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
loop: The event loop to shutdown.
|
|
230
|
+
force: If True, use minimal timeouts for faster exit.
|
|
231
|
+
"""
|
|
232
|
+
with self._tasks_lock:
|
|
233
|
+
tasks = list(self._tasks.values())
|
|
234
|
+
self._tasks.clear()
|
|
235
|
+
|
|
236
|
+
# Use shorter timeouts when force is True
|
|
237
|
+
wait_timeout = 0.3 if force else 1.5
|
|
238
|
+
kill_timeout = 0.2 if force else 0.5
|
|
239
|
+
|
|
240
|
+
for task in tasks:
|
|
241
|
+
try:
|
|
242
|
+
task.killed = True
|
|
243
|
+
with contextlib.suppress(ProcessLookupError):
|
|
244
|
+
task.process.kill()
|
|
245
|
+
try:
|
|
246
|
+
with contextlib.suppress(ProcessLookupError):
|
|
247
|
+
await asyncio.wait_for(task.process.wait(), timeout=wait_timeout)
|
|
248
|
+
except asyncio.TimeoutError:
|
|
249
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
250
|
+
task.process.kill()
|
|
251
|
+
with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
|
|
252
|
+
await asyncio.wait_for(task.process.wait(), timeout=kill_timeout)
|
|
253
|
+
task.exit_code = task.process.returncode or -1
|
|
254
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
255
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
256
|
+
_safe_log_exception(
|
|
257
|
+
"Error shutting down background task",
|
|
258
|
+
task_id=task.id,
|
|
259
|
+
command=task.command,
|
|
260
|
+
)
|
|
261
|
+
finally:
|
|
262
|
+
await _finalize_reader_tasks(task.reader_tasks, timeout=0.3 if force else 1.0)
|
|
263
|
+
task.done_event.set()
|
|
264
|
+
|
|
265
|
+
current = asyncio.current_task()
|
|
266
|
+
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
267
|
+
for pending_task in pending:
|
|
268
|
+
pending_task.cancel()
|
|
269
|
+
if pending:
|
|
270
|
+
with contextlib.suppress(Exception):
|
|
271
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
66
272
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return _background_loop
|
|
273
|
+
with contextlib.suppress(Exception):
|
|
274
|
+
await loop.shutdown_asyncgens()
|
|
70
275
|
|
|
71
|
-
loop = asyncio.new_event_loop()
|
|
72
|
-
ready = threading.Event()
|
|
73
276
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
ready.set()
|
|
77
|
-
loop.run_forever()
|
|
277
|
+
# Module-level functions that delegate to the singleton manager
|
|
278
|
+
# These maintain backward compatibility with existing code
|
|
78
279
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
daemon=True,
|
|
83
|
-
)
|
|
84
|
-
thread.start()
|
|
85
|
-
ready.wait()
|
|
280
|
+
def _get_manager() -> BackgroundShellManager:
|
|
281
|
+
"""Get the singleton manager instance."""
|
|
282
|
+
return BackgroundShellManager.get_instance()
|
|
86
283
|
|
|
87
|
-
_background_loop = loop
|
|
88
|
-
_background_thread = thread
|
|
89
|
-
_register_shutdown_hook()
|
|
90
|
-
return loop
|
|
91
284
|
|
|
285
|
+
def _get_tasks_lock() -> threading.Lock:
|
|
286
|
+
"""Get the tasks lock from the manager."""
|
|
287
|
+
return _get_manager().tasks_lock
|
|
92
288
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
289
|
+
|
|
290
|
+
def _get_tasks() -> Dict[str, BackgroundTask]:
|
|
291
|
+
"""Get the tasks dict from the manager."""
|
|
292
|
+
return _get_manager().tasks
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _safe_log_exception(message: str, **extra: Any) -> None:
|
|
296
|
+
"""Log an exception but never let logging failures bubble up."""
|
|
297
|
+
try:
|
|
298
|
+
logger.exception(message, extra=extra)
|
|
299
|
+
except (OSError, RuntimeError, ValueError):
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _ensure_background_loop() -> asyncio.AbstractEventLoop:
|
|
304
|
+
"""Create (or return) a dedicated loop for background processes."""
|
|
305
|
+
return _get_manager().ensure_loop()
|
|
99
306
|
|
|
100
307
|
|
|
101
308
|
def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
|
|
102
309
|
"""Run a coroutine on the background loop and return a thread-safe future."""
|
|
103
|
-
|
|
104
|
-
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
310
|
+
return _get_manager().submit_to_loop(coro)
|
|
105
311
|
|
|
106
312
|
|
|
107
313
|
async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
@@ -112,7 +318,7 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
|
112
318
|
if not chunk:
|
|
113
319
|
break
|
|
114
320
|
text = chunk.decode("utf-8", errors="replace")
|
|
115
|
-
with
|
|
321
|
+
with _get_tasks_lock():
|
|
116
322
|
sink.append(text)
|
|
117
323
|
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
118
324
|
if isinstance(exc, asyncio.CancelledError):
|
|
@@ -147,15 +353,15 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
147
353
|
await asyncio.wait_for(task.process.wait(), timeout=task.timeout)
|
|
148
354
|
else:
|
|
149
355
|
await task.process.wait()
|
|
150
|
-
with
|
|
356
|
+
with _get_tasks_lock():
|
|
151
357
|
task.exit_code = task.process.returncode
|
|
152
358
|
except asyncio.TimeoutError:
|
|
153
359
|
logger.warning(f"Background task {task.id} timed out after {task.timeout}s: {task.command}")
|
|
154
|
-
with
|
|
360
|
+
with _get_tasks_lock():
|
|
155
361
|
task.timed_out = True
|
|
156
362
|
task.process.kill()
|
|
157
363
|
await task.process.wait()
|
|
158
|
-
with
|
|
364
|
+
with _get_tasks_lock():
|
|
159
365
|
task.exit_code = -1
|
|
160
366
|
except asyncio.CancelledError:
|
|
161
367
|
return
|
|
@@ -166,7 +372,7 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
166
372
|
exc,
|
|
167
373
|
extra={"task_id": task.id, "command": task.command},
|
|
168
374
|
)
|
|
169
|
-
with
|
|
375
|
+
with _get_tasks_lock():
|
|
170
376
|
task.exit_code = -1
|
|
171
377
|
finally:
|
|
172
378
|
# Ensure readers are finished before marking done.
|
|
@@ -196,8 +402,8 @@ async def _start_background_command(
|
|
|
196
402
|
start_time=_loop_time(),
|
|
197
403
|
timeout=timeout,
|
|
198
404
|
)
|
|
199
|
-
with
|
|
200
|
-
|
|
405
|
+
with _get_tasks_lock():
|
|
406
|
+
_get_tasks()[task_id] = record
|
|
201
407
|
|
|
202
408
|
# Start stream pumps and monitor task.
|
|
203
409
|
if process.stdout:
|
|
@@ -247,11 +453,12 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
|
|
|
247
453
|
|
|
248
454
|
If consume is True, buffered stdout/stderr are cleared after reading.
|
|
249
455
|
"""
|
|
250
|
-
|
|
251
|
-
|
|
456
|
+
tasks = _get_tasks()
|
|
457
|
+
with _get_tasks_lock():
|
|
458
|
+
if task_id not in tasks:
|
|
252
459
|
raise KeyError(f"No background task found with id '{task_id}'")
|
|
253
460
|
|
|
254
|
-
task =
|
|
461
|
+
task = tasks[task_id]
|
|
255
462
|
stdout = "".join(task.stdout_chunks)
|
|
256
463
|
stderr = "".join(task.stderr_chunks)
|
|
257
464
|
|
|
@@ -277,8 +484,9 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
277
484
|
KILL_WAIT_SECONDS = 2.0
|
|
278
485
|
|
|
279
486
|
async def _kill(task_id: str) -> bool:
|
|
280
|
-
|
|
281
|
-
|
|
487
|
+
tasks = _get_tasks()
|
|
488
|
+
with _get_tasks_lock():
|
|
489
|
+
task = tasks.get(task_id)
|
|
282
490
|
if not task:
|
|
283
491
|
return False
|
|
284
492
|
|
|
@@ -296,7 +504,7 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
296
504
|
task.process.kill()
|
|
297
505
|
await asyncio.wait_for(task.process.wait(), timeout=1.0)
|
|
298
506
|
|
|
299
|
-
with
|
|
507
|
+
with _get_tasks_lock():
|
|
300
508
|
task.exit_code = task.process.returncode or -1
|
|
301
509
|
return True
|
|
302
510
|
finally:
|
|
@@ -309,82 +517,45 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
309
517
|
|
|
310
518
|
def list_background_tasks() -> List[str]:
|
|
311
519
|
"""Return known background task ids."""
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
task.done_event.set()
|
|
346
|
-
|
|
347
|
-
current = asyncio.current_task()
|
|
348
|
-
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
349
|
-
for pending_task in pending:
|
|
350
|
-
pending_task.cancel()
|
|
351
|
-
if pending:
|
|
352
|
-
with contextlib.suppress(Exception):
|
|
353
|
-
await asyncio.gather(*pending, return_exceptions=True)
|
|
354
|
-
|
|
355
|
-
with contextlib.suppress(Exception):
|
|
356
|
-
await loop.shutdown_asyncgens()
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def shutdown_background_shell() -> None:
|
|
360
|
-
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
|
|
361
|
-
global _background_loop, _background_thread
|
|
520
|
+
_prune_background_tasks()
|
|
521
|
+
with _get_tasks_lock():
|
|
522
|
+
return list(_get_tasks().keys())
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _prune_background_tasks(max_age_seconds: Optional[float] = None) -> int:
|
|
526
|
+
"""Remove finished background tasks older than the TTL."""
|
|
527
|
+
ttl = DEFAULT_TASK_TTL_SEC if max_age_seconds is None else max_age_seconds
|
|
528
|
+
if ttl is None or ttl <= 0:
|
|
529
|
+
return 0
|
|
530
|
+
now = _loop_time()
|
|
531
|
+
removed = 0
|
|
532
|
+
tasks = _get_tasks()
|
|
533
|
+
with _get_tasks_lock():
|
|
534
|
+
for task_id, task in list(tasks.items()):
|
|
535
|
+
if task.exit_code is None:
|
|
536
|
+
continue
|
|
537
|
+
age = (now - task.start_time) if task.start_time else 0.0
|
|
538
|
+
if age > ttl:
|
|
539
|
+
tasks.pop(task_id, None)
|
|
540
|
+
removed += 1
|
|
541
|
+
return removed
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def shutdown_background_shell(force: bool = False) -> None:
|
|
545
|
+
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings.
|
|
546
|
+
|
|
547
|
+
This function maintains backward compatibility by delegating to the manager.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
force: If True, use minimal timeouts for faster exit.
|
|
551
|
+
"""
|
|
552
|
+
_get_manager().shutdown(force=force)
|
|
362
553
|
|
|
363
|
-
loop = _background_loop
|
|
364
|
-
thread = _background_thread
|
|
365
554
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
_background_thread = None
|
|
369
|
-
return
|
|
555
|
+
def reset_background_shell_for_testing() -> None:
|
|
556
|
+
"""Reset all background shell state. Useful for testing.
|
|
370
557
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
fut.result(timeout=3)
|
|
376
|
-
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
377
|
-
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
378
|
-
try:
|
|
379
|
-
loop.call_soon_threadsafe(loop.stop)
|
|
380
|
-
except (RuntimeError, OSError):
|
|
381
|
-
logger.debug("Failed to stop background loop", exc_info=True)
|
|
382
|
-
else:
|
|
383
|
-
loop.run_until_complete(_shutdown_loop(loop))
|
|
384
|
-
finally:
|
|
385
|
-
if thread and thread.is_alive():
|
|
386
|
-
thread.join(timeout=2)
|
|
387
|
-
with contextlib.suppress(Exception):
|
|
388
|
-
loop.close()
|
|
389
|
-
_background_loop = None
|
|
390
|
-
_background_thread = None
|
|
558
|
+
This function shuts down the current manager instance and clears it,
|
|
559
|
+
allowing a fresh instance to be created on next access.
|
|
560
|
+
"""
|
|
561
|
+
BackgroundShellManager.reset_instance()
|
ripperdoc/tools/bash_tool.py
CHANGED
|
@@ -41,7 +41,6 @@ from ripperdoc.utils.output_utils import (
|
|
|
41
41
|
truncate_output,
|
|
42
42
|
)
|
|
43
43
|
from ripperdoc.utils.permissions.path_validation_utils import validate_shell_command_paths
|
|
44
|
-
from ripperdoc.utils.permissions.shell_command_validation import validate_shell_command
|
|
45
44
|
from ripperdoc.utils.permissions.tool_permission_utils import (
|
|
46
45
|
evaluate_shell_command_permissions,
|
|
47
46
|
is_command_read_only,
|
|
@@ -341,9 +340,8 @@ build projects, run tests, and interact with the file system."""
|
|
|
341
340
|
allow_rules,
|
|
342
341
|
deny_rules,
|
|
343
342
|
allowed_dirs,
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
read_only_detector=lambda cmd, detector: is_command_read_only(cmd),
|
|
343
|
+
# danger_detector uses default: validate_shell_command(cmd).behavior != "passthrough"
|
|
344
|
+
# read_only_detector uses default: _is_command_read_only
|
|
347
345
|
)
|
|
348
346
|
|
|
349
347
|
# Background executions need an explicit confirmation even if heuristics
|
|
@@ -384,6 +382,43 @@ build projects, run tests, and interact with the file system."""
|
|
|
384
382
|
result=False, message="Sandbox mode requested but not available."
|
|
385
383
|
)
|
|
386
384
|
|
|
385
|
+
# Validate shell_executable if provided
|
|
386
|
+
if input_data.shell_executable:
|
|
387
|
+
shell_path = Path(input_data.shell_executable)
|
|
388
|
+
# Must be an absolute path
|
|
389
|
+
if not shell_path.is_absolute():
|
|
390
|
+
return ValidationResult(
|
|
391
|
+
result=False,
|
|
392
|
+
message=f"shell_executable must be an absolute path: {input_data.shell_executable}",
|
|
393
|
+
)
|
|
394
|
+
# Must exist and be a file
|
|
395
|
+
if not shell_path.exists():
|
|
396
|
+
return ValidationResult(
|
|
397
|
+
result=False,
|
|
398
|
+
message=f"shell_executable not found: {input_data.shell_executable}",
|
|
399
|
+
)
|
|
400
|
+
if not shell_path.is_file():
|
|
401
|
+
return ValidationResult(
|
|
402
|
+
result=False,
|
|
403
|
+
message=f"shell_executable is not a file: {input_data.shell_executable}",
|
|
404
|
+
)
|
|
405
|
+
# Must be executable
|
|
406
|
+
if not os.access(shell_path, os.X_OK):
|
|
407
|
+
return ValidationResult(
|
|
408
|
+
result=False,
|
|
409
|
+
message=f"shell_executable is not executable: {input_data.shell_executable}",
|
|
410
|
+
)
|
|
411
|
+
# Must be in a safe system directory or match known shell patterns
|
|
412
|
+
safe_dirs = {"/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"}
|
|
413
|
+
shell_name = shell_path.name.lower()
|
|
414
|
+
known_shells = {"bash", "sh", "zsh", "fish", "dash", "ksh", "tcsh", "csh"}
|
|
415
|
+
parent_dir = str(shell_path.parent)
|
|
416
|
+
if parent_dir not in safe_dirs and shell_name not in known_shells:
|
|
417
|
+
return ValidationResult(
|
|
418
|
+
result=False,
|
|
419
|
+
message=f"shell_executable must be a known shell in a standard location: {input_data.shell_executable}",
|
|
420
|
+
)
|
|
421
|
+
|
|
387
422
|
# Note: Path validation for sensitive directories (cd/find to /usr, /etc, etc.)
|
|
388
423
|
# is now handled in check_permissions() to allow user confirmation for read-only ops.
|
|
389
424
|
|
|
@@ -396,15 +431,9 @@ build projects, run tests, and interact with the file system."""
|
|
|
396
431
|
result=False, message="This command cannot be run in background"
|
|
397
432
|
)
|
|
398
433
|
|
|
399
|
-
validation
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if context and hasattr(context, 'yolo_mode') and context.yolo_mode \
|
|
403
|
-
and "shell metacharacters" in validation.message:
|
|
404
|
-
# Allow commands with shell metacharacters in yolo mode
|
|
405
|
-
return ValidationResult(result=True)
|
|
406
|
-
return ValidationResult(result=False, message=validation.message)
|
|
407
|
-
|
|
434
|
+
# Note: Security validation (shell metacharacters, destructive commands, etc.)
|
|
435
|
+
# is handled in check_permissions() via evaluate_shell_command_permissions().
|
|
436
|
+
# validate_input() should only perform format/parameter validation.
|
|
408
437
|
return ValidationResult(result=True)
|
|
409
438
|
|
|
410
439
|
def render_result_for_assistant(self, output: BashToolOutput) -> str:
|