ripperdoc 0.2.10__py3-none-any.whl → 0.3.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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +164 -57
- ripperdoc/cli/commands/__init__.py +4 -0
- ripperdoc/cli/commands/agents_cmd.py +3 -7
- ripperdoc/cli/commands/doctor_cmd.py +29 -0
- ripperdoc/cli/commands/memory_cmd.py +2 -1
- ripperdoc/cli/commands/models_cmd.py +61 -5
- ripperdoc/cli/commands/resume_cmd.py +1 -0
- ripperdoc/cli/commands/skills_cmd.py +103 -0
- ripperdoc/cli/commands/stats_cmd.py +4 -4
- ripperdoc/cli/commands/status_cmd.py +10 -0
- ripperdoc/cli/commands/tasks_cmd.py +6 -3
- ripperdoc/cli/commands/themes_cmd.py +139 -0
- ripperdoc/cli/ui/file_mention_completer.py +63 -13
- ripperdoc/cli/ui/helpers.py +6 -3
- ripperdoc/cli/ui/interrupt_handler.py +34 -0
- ripperdoc/cli/ui/panels.py +13 -8
- ripperdoc/cli/ui/rich_ui.py +451 -32
- ripperdoc/cli/ui/spinner.py +68 -5
- ripperdoc/cli/ui/tool_renderers.py +10 -9
- ripperdoc/cli/ui/wizard.py +18 -11
- ripperdoc/core/agents.py +4 -0
- ripperdoc/core/config.py +235 -0
- ripperdoc/core/default_tools.py +1 -0
- ripperdoc/core/hooks/llm_callback.py +0 -1
- ripperdoc/core/hooks/manager.py +6 -0
- ripperdoc/core/permissions.py +82 -5
- ripperdoc/core/providers/openai.py +55 -9
- ripperdoc/core/query.py +349 -108
- ripperdoc/core/query_utils.py +17 -14
- ripperdoc/core/skills.py +1 -0
- ripperdoc/core/theme.py +298 -0
- ripperdoc/core/tool.py +8 -3
- ripperdoc/protocol/__init__.py +14 -0
- ripperdoc/protocol/models.py +300 -0
- ripperdoc/protocol/stdio.py +1453 -0
- ripperdoc/tools/background_shell.py +49 -5
- ripperdoc/tools/bash_tool.py +75 -9
- ripperdoc/tools/file_edit_tool.py +98 -29
- ripperdoc/tools/file_read_tool.py +139 -8
- ripperdoc/tools/file_write_tool.py +46 -3
- ripperdoc/tools/grep_tool.py +98 -8
- ripperdoc/tools/lsp_tool.py +9 -15
- ripperdoc/tools/multi_edit_tool.py +26 -3
- ripperdoc/tools/skill_tool.py +52 -1
- ripperdoc/tools/task_tool.py +33 -8
- ripperdoc/utils/file_watch.py +12 -6
- ripperdoc/utils/image_utils.py +125 -0
- ripperdoc/utils/log.py +30 -3
- ripperdoc/utils/lsp.py +9 -3
- ripperdoc/utils/mcp.py +80 -18
- ripperdoc/utils/message_formatting.py +2 -2
- ripperdoc/utils/messages.py +177 -32
- ripperdoc/utils/pending_messages.py +50 -0
- ripperdoc/utils/permissions/shell_command_validation.py +3 -3
- ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
- ripperdoc/utils/platform.py +198 -0
- ripperdoc/utils/session_heatmap.py +1 -3
- ripperdoc/utils/session_history.py +2 -2
- ripperdoc/utils/session_stats.py +1 -0
- ripperdoc/utils/shell_utils.py +8 -5
- ripperdoc/utils/todo.py +0 -6
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -408
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
import uuid
|
|
15
15
|
import weakref
|
|
16
16
|
from dataclasses import dataclass, field
|
|
17
|
-
from typing import Any, Dict, List, Optional
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
18
18
|
|
|
19
19
|
import atexit
|
|
20
20
|
|
|
@@ -34,6 +34,7 @@ class BackgroundTask:
|
|
|
34
34
|
process: asyncio.subprocess.Process
|
|
35
35
|
start_time: float
|
|
36
36
|
timeout: Optional[float] = None
|
|
37
|
+
end_time: Optional[float] = None
|
|
37
38
|
stdout_chunks: List[str] = field(default_factory=list)
|
|
38
39
|
stderr_chunks: List[str] = field(default_factory=list)
|
|
39
40
|
exit_code: Optional[int] = None
|
|
@@ -41,6 +42,7 @@ class BackgroundTask:
|
|
|
41
42
|
timed_out: bool = False
|
|
42
43
|
reader_tasks: List[asyncio.Task] = field(default_factory=list)
|
|
43
44
|
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
45
|
+
completion_callbacks: List[Callable[["BackgroundTask"], None]] = field(default_factory=list)
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
DEFAULT_TASK_TTL_SEC = float(os.getenv("RIPPERDOC_BASH_TASK_TTL_SEC", "3600"))
|
|
@@ -251,6 +253,7 @@ class BackgroundShellManager:
|
|
|
251
253
|
with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
|
|
252
254
|
await asyncio.wait_for(task.process.wait(), timeout=kill_timeout)
|
|
253
255
|
task.exit_code = task.process.returncode or -1
|
|
256
|
+
task.end_time = task.end_time or _loop_time()
|
|
254
257
|
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
255
258
|
if not isinstance(exc, asyncio.CancelledError):
|
|
256
259
|
_safe_log_exception(
|
|
@@ -261,6 +264,7 @@ class BackgroundShellManager:
|
|
|
261
264
|
finally:
|
|
262
265
|
await _finalize_reader_tasks(task.reader_tasks, timeout=0.3 if force else 1.0)
|
|
263
266
|
task.done_event.set()
|
|
267
|
+
_run_completion_callbacks(task)
|
|
264
268
|
|
|
265
269
|
current = asyncio.current_task()
|
|
266
270
|
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
@@ -277,6 +281,7 @@ class BackgroundShellManager:
|
|
|
277
281
|
# Module-level functions that delegate to the singleton manager
|
|
278
282
|
# These maintain backward compatibility with existing code
|
|
279
283
|
|
|
284
|
+
|
|
280
285
|
def _get_manager() -> BackgroundShellManager:
|
|
281
286
|
"""Get the singleton manager instance."""
|
|
282
287
|
return BackgroundShellManager.get_instance()
|
|
@@ -346,6 +351,21 @@ async def _finalize_reader_tasks(reader_tasks: List[asyncio.Task], timeout: floa
|
|
|
346
351
|
await asyncio.gather(*reader_tasks, return_exceptions=True)
|
|
347
352
|
|
|
348
353
|
|
|
354
|
+
def _run_completion_callbacks(task: BackgroundTask) -> None:
|
|
355
|
+
"""Invoke completion callbacks safely for a finished task."""
|
|
356
|
+
if not task.completion_callbacks:
|
|
357
|
+
return
|
|
358
|
+
for callback in list(task.completion_callbacks):
|
|
359
|
+
try:
|
|
360
|
+
callback(task)
|
|
361
|
+
except Exception:
|
|
362
|
+
logger.debug(
|
|
363
|
+
"Background task completion callback failed",
|
|
364
|
+
exc_info=True,
|
|
365
|
+
extra={"task_id": task.id, "command": task.command},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
349
369
|
async def _monitor_task(task: BackgroundTask) -> None:
|
|
350
370
|
"""Wait for a background process to finish or timeout, then mark status."""
|
|
351
371
|
try:
|
|
@@ -355,6 +375,7 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
355
375
|
await task.process.wait()
|
|
356
376
|
with _get_tasks_lock():
|
|
357
377
|
task.exit_code = task.process.returncode
|
|
378
|
+
task.end_time = task.end_time or _loop_time()
|
|
358
379
|
except asyncio.TimeoutError:
|
|
359
380
|
logger.warning(f"Background task {task.id} timed out after {task.timeout}s: {task.command}")
|
|
360
381
|
with _get_tasks_lock():
|
|
@@ -363,6 +384,7 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
363
384
|
await task.process.wait()
|
|
364
385
|
with _get_tasks_lock():
|
|
365
386
|
task.exit_code = -1
|
|
387
|
+
task.end_time = task.end_time or _loop_time()
|
|
366
388
|
except asyncio.CancelledError:
|
|
367
389
|
return
|
|
368
390
|
except (OSError, RuntimeError, ProcessLookupError) as exc:
|
|
@@ -374,14 +396,19 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
374
396
|
)
|
|
375
397
|
with _get_tasks_lock():
|
|
376
398
|
task.exit_code = -1
|
|
399
|
+
task.end_time = task.end_time or _loop_time()
|
|
377
400
|
finally:
|
|
378
401
|
# Ensure readers are finished before marking done.
|
|
379
402
|
await _finalize_reader_tasks(task.reader_tasks)
|
|
380
403
|
task.done_event.set()
|
|
404
|
+
_run_completion_callbacks(task)
|
|
381
405
|
|
|
382
406
|
|
|
383
407
|
async def _start_background_command(
|
|
384
|
-
command: str,
|
|
408
|
+
command: str,
|
|
409
|
+
timeout: Optional[float] = None,
|
|
410
|
+
shell_executable: Optional[str] = None,
|
|
411
|
+
completion_callbacks: Optional[List[Callable[["BackgroundTask"], None]]] = None,
|
|
385
412
|
) -> str:
|
|
386
413
|
"""Launch a background shell command on the dedicated loop."""
|
|
387
414
|
selected_shell = shell_executable or find_suitable_shell()
|
|
@@ -401,6 +428,7 @@ async def _start_background_command(
|
|
|
401
428
|
process=process,
|
|
402
429
|
start_time=_loop_time(),
|
|
403
430
|
timeout=timeout,
|
|
431
|
+
completion_callbacks=list(completion_callbacks or []),
|
|
404
432
|
)
|
|
405
433
|
with _get_tasks_lock():
|
|
406
434
|
_get_tasks()[task_id] = record
|
|
@@ -420,11 +448,16 @@ async def _start_background_command(
|
|
|
420
448
|
|
|
421
449
|
|
|
422
450
|
async def start_background_command(
|
|
423
|
-
command: str,
|
|
451
|
+
command: str,
|
|
452
|
+
timeout: Optional[float] = None,
|
|
453
|
+
shell_executable: Optional[str] = None,
|
|
454
|
+
completion_callbacks: Optional[List[Callable[["BackgroundTask"], None]]] = None,
|
|
424
455
|
) -> str:
|
|
425
456
|
"""Launch a background shell command and return its task id."""
|
|
426
457
|
future = _submit_to_background_loop(
|
|
427
|
-
_start_background_command(
|
|
458
|
+
_start_background_command(
|
|
459
|
+
command, timeout, shell_executable, completion_callbacks=completion_callbacks
|
|
460
|
+
)
|
|
428
461
|
)
|
|
429
462
|
return await asyncio.wrap_future(future)
|
|
430
463
|
|
|
@@ -453,6 +486,7 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
|
|
|
453
486
|
|
|
454
487
|
If consume is True, buffered stdout/stderr are cleared after reading.
|
|
455
488
|
"""
|
|
489
|
+
now = _loop_time()
|
|
456
490
|
tasks = _get_tasks()
|
|
457
491
|
with _get_tasks_lock():
|
|
458
492
|
if task_id not in tasks:
|
|
@@ -462,6 +496,14 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
|
|
|
462
496
|
stdout = "".join(task.stdout_chunks)
|
|
463
497
|
stderr = "".join(task.stderr_chunks)
|
|
464
498
|
|
|
499
|
+
finished = task.exit_code is not None or task.killed or task.timed_out
|
|
500
|
+
if finished and task.end_time is None:
|
|
501
|
+
task.end_time = now
|
|
502
|
+
duration_ms = (
|
|
503
|
+
((task.end_time or now) - task.start_time) * 1000.0 if task.start_time else None
|
|
504
|
+
)
|
|
505
|
+
age_ms = (now - task.start_time) * 1000.0 if task.start_time else None
|
|
506
|
+
|
|
465
507
|
if consume:
|
|
466
508
|
task.stdout_chunks.clear()
|
|
467
509
|
task.stderr_chunks.clear()
|
|
@@ -475,7 +517,8 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
|
|
|
475
517
|
"exit_code": task.exit_code,
|
|
476
518
|
"timed_out": task.timed_out,
|
|
477
519
|
"killed": task.killed,
|
|
478
|
-
"duration_ms":
|
|
520
|
+
"duration_ms": duration_ms,
|
|
521
|
+
"age_ms": age_ms,
|
|
479
522
|
}
|
|
480
523
|
|
|
481
524
|
|
|
@@ -506,6 +549,7 @@ async def kill_background_task(task_id: str) -> bool:
|
|
|
506
549
|
|
|
507
550
|
with _get_tasks_lock():
|
|
508
551
|
task.exit_code = task.process.returncode or -1
|
|
552
|
+
task.end_time = task.end_time or _loop_time()
|
|
509
553
|
return True
|
|
510
554
|
finally:
|
|
511
555
|
await _finalize_reader_tasks(task.reader_tasks)
|
ripperdoc/tools/bash_tool.py
CHANGED
|
@@ -50,6 +50,7 @@ from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_ava
|
|
|
50
50
|
from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
|
|
51
51
|
from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
|
|
52
52
|
from ripperdoc.utils.log import get_logger
|
|
53
|
+
from ripperdoc.utils.platform import IS_WINDOWS
|
|
53
54
|
|
|
54
55
|
logger = get_logger()
|
|
55
56
|
|
|
@@ -597,6 +598,7 @@ build projects, run tests, and interact with the file system."""
|
|
|
597
598
|
sandbox_requested: bool,
|
|
598
599
|
start_time: float,
|
|
599
600
|
input_data: BashToolInput,
|
|
601
|
+
context: Optional[ToolUseContext] = None,
|
|
600
602
|
) -> Optional[BashToolOutput]:
|
|
601
603
|
"""Run a command in background mode.
|
|
602
604
|
|
|
@@ -622,8 +624,50 @@ build projects, run tests, and interact with the file system."""
|
|
|
622
624
|
if input_data.timeout is None
|
|
623
625
|
else (timeout_seconds if timeout_seconds > 0 else None)
|
|
624
626
|
)
|
|
627
|
+
|
|
628
|
+
completion_callbacks = None
|
|
629
|
+
queue = getattr(context, "pending_message_queue", None) if context else None
|
|
630
|
+
if queue is not None:
|
|
631
|
+
|
|
632
|
+
def _notify_completion(task: Any) -> None:
|
|
633
|
+
# Mirror status computation from background_shell._compute_status
|
|
634
|
+
if getattr(task, "killed", False):
|
|
635
|
+
status = "killed"
|
|
636
|
+
elif getattr(task, "timed_out", False):
|
|
637
|
+
status = "failed"
|
|
638
|
+
else:
|
|
639
|
+
exit_code = getattr(task, "exit_code", None)
|
|
640
|
+
status = (
|
|
641
|
+
"running"
|
|
642
|
+
if exit_code is None
|
|
643
|
+
else ("completed" if exit_code == 0 else "failed")
|
|
644
|
+
)
|
|
645
|
+
exit_code = getattr(task, "exit_code", None)
|
|
646
|
+
status_line = (
|
|
647
|
+
f"Background bash task {getattr(task, 'id', '')} finished with status: {status}"
|
|
648
|
+
)
|
|
649
|
+
if exit_code is not None:
|
|
650
|
+
status_line += f" (exit code {exit_code})"
|
|
651
|
+
details = [
|
|
652
|
+
status_line,
|
|
653
|
+
f"Command: {effective_command}",
|
|
654
|
+
"Use BashOutput with this task id to read stdout/stderr and continue.",
|
|
655
|
+
]
|
|
656
|
+
queue.enqueue_text(
|
|
657
|
+
"\n".join(details),
|
|
658
|
+
metadata={
|
|
659
|
+
"source": "background_bash",
|
|
660
|
+
"background_task_id": getattr(task, "id", None),
|
|
661
|
+
},
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
completion_callbacks = [_notify_completion] # type: ignore[assignment]
|
|
665
|
+
|
|
625
666
|
task_id = await start_background_command(
|
|
626
|
-
final_command,
|
|
667
|
+
final_command,
|
|
668
|
+
timeout=bg_timeout,
|
|
669
|
+
shell_executable=resolved_shell,
|
|
670
|
+
completion_callbacks=completion_callbacks, # type: ignore[arg-type]
|
|
627
671
|
)
|
|
628
672
|
|
|
629
673
|
return BashToolOutput(
|
|
@@ -889,6 +933,7 @@ build projects, run tests, and interact with the file system."""
|
|
|
889
933
|
sandbox_requested,
|
|
890
934
|
start,
|
|
891
935
|
input_data,
|
|
936
|
+
context,
|
|
892
937
|
)
|
|
893
938
|
if output:
|
|
894
939
|
yield ToolResult(
|
|
@@ -1021,35 +1066,56 @@ build projects, run tests, and interact with the file system."""
|
|
|
1021
1066
|
finally:
|
|
1022
1067
|
self._current_is_read_only = previous_read_only
|
|
1023
1068
|
if sandbox_cleanup:
|
|
1024
|
-
with contextlib.suppress(
|
|
1069
|
+
with contextlib.suppress(OSError, IOError, PermissionError):
|
|
1025
1070
|
sandbox_cleanup()
|
|
1026
1071
|
|
|
1027
1072
|
async def _force_kill_process(
|
|
1028
1073
|
self, process: asyncio.subprocess.Process, grace_seconds: float = KILL_GRACE_SECONDS
|
|
1029
1074
|
) -> None:
|
|
1030
|
-
"""Attempt to terminate a process group and avoid hanging waits.
|
|
1075
|
+
"""Attempt to terminate a process group and avoid hanging waits.
|
|
1076
|
+
|
|
1077
|
+
Platform differences:
|
|
1078
|
+
- Unix: Uses killpg with SIGTERM/SIGKILL to terminate process groups
|
|
1079
|
+
- Windows: Uses process.terminate()/kill() which sends appropriate signals
|
|
1080
|
+
"""
|
|
1031
1081
|
if process.returncode is not None:
|
|
1032
1082
|
return
|
|
1033
1083
|
|
|
1034
1084
|
def _terminate() -> None:
|
|
1035
|
-
if
|
|
1036
|
-
|
|
1085
|
+
if IS_WINDOWS:
|
|
1086
|
+
# Windows: use process.terminate() which is cross-platform
|
|
1087
|
+
process.terminate()
|
|
1088
|
+
elif hasattr(os, "killpg"):
|
|
1089
|
+
# Unix: terminate the entire process group
|
|
1090
|
+
try:
|
|
1091
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
1092
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1093
|
+
# Fallback to single process termination
|
|
1094
|
+
process.terminate()
|
|
1037
1095
|
else:
|
|
1038
1096
|
process.terminate()
|
|
1039
1097
|
|
|
1040
1098
|
def _kill() -> None:
|
|
1041
|
-
if
|
|
1042
|
-
|
|
1099
|
+
if IS_WINDOWS:
|
|
1100
|
+
# Windows: use process.kill() which forcefully terminates
|
|
1101
|
+
process.kill()
|
|
1102
|
+
elif hasattr(os, "killpg") and hasattr(signal, "SIGKILL"):
|
|
1103
|
+
# Unix: forcefully kill the entire process group
|
|
1104
|
+
try:
|
|
1105
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
1106
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1107
|
+
# Fallback to single process kill
|
|
1108
|
+
process.kill()
|
|
1043
1109
|
else:
|
|
1044
1110
|
process.kill()
|
|
1045
1111
|
|
|
1046
|
-
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1112
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
1047
1113
|
_terminate()
|
|
1048
1114
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
1049
1115
|
await asyncio.wait_for(process.wait(), timeout=grace_seconds)
|
|
1050
1116
|
return
|
|
1051
1117
|
|
|
1052
|
-
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1118
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
1053
1119
|
_kill()
|
|
1054
1120
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
1055
1121
|
await asyncio.wait_for(process.wait(), timeout=grace_seconds)
|
|
@@ -19,18 +19,36 @@ from ripperdoc.core.tool import (
|
|
|
19
19
|
ValidationResult,
|
|
20
20
|
)
|
|
21
21
|
from ripperdoc.utils.log import get_logger
|
|
22
|
+
from ripperdoc.utils.platform import HAS_FCNTL
|
|
22
23
|
from ripperdoc.utils.file_watch import record_snapshot
|
|
23
24
|
from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
25
|
+
from ripperdoc.tools.file_read_tool import detect_file_encoding
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
import fcntl
|
|
27
|
+
logger = get_logger()
|
|
28
28
|
|
|
29
|
-
HAS_FCNTL = True
|
|
30
|
-
except ImportError:
|
|
31
|
-
HAS_FCNTL = False
|
|
32
29
|
|
|
33
|
-
|
|
30
|
+
def determine_edit_encoding(file_path: str, new_content: str) -> str:
|
|
31
|
+
"""Determine encoding for editing a file.
|
|
32
|
+
|
|
33
|
+
Detects the file's current encoding and verifies the new content
|
|
34
|
+
can be encoded with it. Falls back to UTF-8 if needed.
|
|
35
|
+
"""
|
|
36
|
+
detected_encoding, _ = detect_file_encoding(file_path)
|
|
37
|
+
|
|
38
|
+
if not detected_encoding:
|
|
39
|
+
return "utf-8"
|
|
40
|
+
|
|
41
|
+
# Verify new content can be encoded
|
|
42
|
+
try:
|
|
43
|
+
new_content.encode(detected_encoding)
|
|
44
|
+
return detected_encoding
|
|
45
|
+
except (UnicodeEncodeError, LookupError):
|
|
46
|
+
logger.info(
|
|
47
|
+
"New content cannot be encoded with %s, falling back to UTF-8 for %s",
|
|
48
|
+
detected_encoding,
|
|
49
|
+
file_path,
|
|
50
|
+
)
|
|
51
|
+
return "utf-8"
|
|
34
52
|
|
|
35
53
|
|
|
36
54
|
@contextlib.contextmanager
|
|
@@ -49,6 +67,8 @@ def _file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, N
|
|
|
49
67
|
yield
|
|
50
68
|
return
|
|
51
69
|
|
|
70
|
+
import fcntl
|
|
71
|
+
|
|
52
72
|
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
53
73
|
try:
|
|
54
74
|
fcntl.flock(file_handle.fileno(), lock_type)
|
|
@@ -221,31 +241,66 @@ match exactly (including whitespace and indentation)."""
|
|
|
221
241
|
file_state_cache = getattr(context, "file_state_cache", {})
|
|
222
242
|
file_snapshot = file_state_cache.get(abs_file_path)
|
|
223
243
|
|
|
244
|
+
# Detect file encoding before opening
|
|
245
|
+
file_encoding, _ = detect_file_encoding(abs_file_path)
|
|
246
|
+
if not file_encoding:
|
|
247
|
+
file_encoding = "utf-8"
|
|
248
|
+
|
|
224
249
|
try:
|
|
225
250
|
# Open file with exclusive lock to prevent concurrent modifications
|
|
226
251
|
# Use r+ mode to get a file handle we can lock before reading
|
|
227
|
-
|
|
252
|
+
#
|
|
253
|
+
# TOCTOU mitigation strategy:
|
|
254
|
+
# 1. Record mtime immediately after open (pre_lock_mtime)
|
|
255
|
+
# 2. Acquire exclusive lock
|
|
256
|
+
# 3. Check mtime again after lock (post_lock_mtime)
|
|
257
|
+
# 4. If pre != post, file was modified in the window between open and lock
|
|
258
|
+
# 5. Also validate against cached snapshot timestamp
|
|
259
|
+
with open(abs_file_path, "r+", encoding=file_encoding) as f:
|
|
260
|
+
# Record mtime immediately after open, before acquiring lock
|
|
261
|
+
try:
|
|
262
|
+
pre_lock_mtime = os.fstat(f.fileno()).st_mtime
|
|
263
|
+
except OSError:
|
|
264
|
+
pre_lock_mtime = None
|
|
265
|
+
|
|
228
266
|
with _file_lock(f, exclusive=True):
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
267
|
+
# Check mtime after acquiring lock to detect modifications
|
|
268
|
+
# during the window between open() and lock acquisition
|
|
269
|
+
try:
|
|
270
|
+
post_lock_mtime = os.fstat(f.fileno()).st_mtime
|
|
271
|
+
except OSError:
|
|
272
|
+
post_lock_mtime = None
|
|
273
|
+
|
|
274
|
+
# Detect modification during open->lock window
|
|
275
|
+
if pre_lock_mtime is not None and post_lock_mtime is not None:
|
|
276
|
+
if post_lock_mtime > pre_lock_mtime:
|
|
277
|
+
output = FileEditToolOutput(
|
|
278
|
+
file_path=input_data.file_path,
|
|
279
|
+
replacements_made=0,
|
|
280
|
+
success=False,
|
|
281
|
+
message="File was modified while acquiring lock. Please retry.",
|
|
282
|
+
)
|
|
283
|
+
yield ToolResult(
|
|
284
|
+
data=output,
|
|
285
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
# Validate against cached snapshot timestamp
|
|
290
|
+
if file_snapshot and post_lock_mtime is not None:
|
|
291
|
+
if post_lock_mtime > file_snapshot.timestamp:
|
|
292
|
+
output = FileEditToolOutput(
|
|
293
|
+
file_path=input_data.file_path,
|
|
294
|
+
replacements_made=0,
|
|
295
|
+
success=False,
|
|
296
|
+
message="File has been modified since read, either by the user "
|
|
297
|
+
"or by a linter. Read it again before attempting to edit it.",
|
|
298
|
+
)
|
|
299
|
+
yield ToolResult(
|
|
300
|
+
data=output,
|
|
301
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
302
|
+
)
|
|
303
|
+
return
|
|
249
304
|
|
|
250
305
|
# Read content while holding the lock
|
|
251
306
|
content = f.read()
|
|
@@ -292,6 +347,19 @@ match exactly (including whitespace and indentation)."""
|
|
|
292
347
|
)
|
|
293
348
|
replacements = 1
|
|
294
349
|
|
|
350
|
+
# Verify new content can be encoded with file's encoding
|
|
351
|
+
# If not, fall back to UTF-8
|
|
352
|
+
write_encoding = file_encoding
|
|
353
|
+
try:
|
|
354
|
+
new_content.encode(file_encoding)
|
|
355
|
+
except (UnicodeEncodeError, LookupError):
|
|
356
|
+
logger.info(
|
|
357
|
+
"New content cannot be encoded with %s, using UTF-8 for %s",
|
|
358
|
+
file_encoding,
|
|
359
|
+
abs_file_path,
|
|
360
|
+
)
|
|
361
|
+
write_encoding = "utf-8"
|
|
362
|
+
|
|
295
363
|
# Atomic write: write to temp file then rename
|
|
296
364
|
# This ensures the file is either fully written or not at all
|
|
297
365
|
file_dir = os.path.dirname(abs_file_path)
|
|
@@ -301,7 +369,7 @@ match exactly (including whitespace and indentation)."""
|
|
|
301
369
|
dir=file_dir, prefix=".ripperdoc_edit_", suffix=".tmp"
|
|
302
370
|
)
|
|
303
371
|
try:
|
|
304
|
-
with os.fdopen(fd, "w", encoding=
|
|
372
|
+
with os.fdopen(fd, "w", encoding=write_encoding) as temp_f:
|
|
305
373
|
temp_f.write(new_content)
|
|
306
374
|
# Preserve original file permissions
|
|
307
375
|
original_stat = os.fstat(f.fileno())
|
|
@@ -345,6 +413,7 @@ match exactly (including whitespace and indentation)."""
|
|
|
345
413
|
abs_file_path,
|
|
346
414
|
new_content,
|
|
347
415
|
getattr(context, "file_state_cache", {}),
|
|
416
|
+
encoding=write_encoding,
|
|
348
417
|
)
|
|
349
418
|
except (OSError, IOError, RuntimeError) as exc:
|
|
350
419
|
logger.warning(
|