ripperdoc 0.2.9__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 +379 -51
- ripperdoc/cli/commands/__init__.py +6 -0
- ripperdoc/cli/commands/agents_cmd.py +128 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/doctor_cmd.py +29 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/memory_cmd.py +2 -1
- ripperdoc/cli/commands/models_cmd.py +63 -7
- ripperdoc/cli/commands/resume_cmd.py +5 -0
- ripperdoc/cli/commands/skills_cmd.py +103 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- 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 +14 -8
- ripperdoc/cli/ui/rich_ui.py +737 -47
- ripperdoc/cli/ui/spinner.py +93 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +10 -9
- ripperdoc/cli/ui/wizard.py +24 -19
- ripperdoc/core/agents.py +14 -3
- ripperdoc/core/config.py +238 -6
- ripperdoc/core/default_tools.py +91 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +58 -0
- ripperdoc/core/hooks/manager.py +6 -0
- ripperdoc/core/permissions.py +160 -9
- ripperdoc/core/providers/openai.py +84 -28
- ripperdoc/core/query.py +489 -87
- ripperdoc/core/query_utils.py +17 -14
- ripperdoc/core/skills.py +1 -0
- ripperdoc/core/theme.py +298 -0
- ripperdoc/core/tool.py +15 -5
- ripperdoc/protocol/__init__.py +14 -0
- ripperdoc/protocol/models.py +300 -0
- ripperdoc/protocol/stdio.py +1453 -0
- ripperdoc/tools/background_shell.py +354 -139
- ripperdoc/tools/bash_tool.py +117 -22
- ripperdoc/tools/file_edit_tool.py +228 -50
- ripperdoc/tools/file_read_tool.py +154 -3
- ripperdoc/tools/file_write_tool.py +53 -11
- ripperdoc/tools/grep_tool.py +98 -8
- ripperdoc/tools/lsp_tool.py +609 -0
- ripperdoc/tools/multi_edit_tool.py +26 -3
- ripperdoc/tools/skill_tool.py +52 -1
- ripperdoc/tools/task_tool.py +539 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +216 -7
- ripperdoc/utils/image_utils.py +125 -0
- ripperdoc/utils/log.py +30 -3
- ripperdoc/utils/lsp.py +812 -0
- ripperdoc/utils/mcp.py +80 -18
- ripperdoc/utils/message_formatting.py +7 -4
- ripperdoc/utils/messages.py +198 -33
- ripperdoc/utils/pending_messages.py +50 -0
- ripperdoc/utils/permissions/shell_command_validation.py +3 -3
- ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
- ripperdoc/utils/platform.py +198 -0
- ripperdoc/utils/session_heatmap.py +242 -0
- ripperdoc/utils/session_history.py +2 -2
- ripperdoc/utils/session_stats.py +294 -0
- ripperdoc/utils/shell_utils.py +8 -5
- ripperdoc/utils/todo.py +0 -6
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
- ripperdoc-0.3.0.dist-info/RECORD +136 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -333
- ripperdoc-0.2.9.dist-info/RECORD +0 -123
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
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,
|
|
@@ -51,6 +50,7 @@ from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_ava
|
|
|
51
50
|
from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
|
|
52
51
|
from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
|
|
53
52
|
from ripperdoc.utils.log import get_logger
|
|
53
|
+
from ripperdoc.utils.platform import IS_WINDOWS
|
|
54
54
|
|
|
55
55
|
logger = get_logger()
|
|
56
56
|
|
|
@@ -341,9 +341,8 @@ build projects, run tests, and interact with the file system."""
|
|
|
341
341
|
allow_rules,
|
|
342
342
|
deny_rules,
|
|
343
343
|
allowed_dirs,
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
read_only_detector=lambda cmd, detector: is_command_read_only(cmd),
|
|
344
|
+
# danger_detector uses default: validate_shell_command(cmd).behavior != "passthrough"
|
|
345
|
+
# read_only_detector uses default: _is_command_read_only
|
|
347
346
|
)
|
|
348
347
|
|
|
349
348
|
# Background executions need an explicit confirmation even if heuristics
|
|
@@ -384,6 +383,43 @@ build projects, run tests, and interact with the file system."""
|
|
|
384
383
|
result=False, message="Sandbox mode requested but not available."
|
|
385
384
|
)
|
|
386
385
|
|
|
386
|
+
# Validate shell_executable if provided
|
|
387
|
+
if input_data.shell_executable:
|
|
388
|
+
shell_path = Path(input_data.shell_executable)
|
|
389
|
+
# Must be an absolute path
|
|
390
|
+
if not shell_path.is_absolute():
|
|
391
|
+
return ValidationResult(
|
|
392
|
+
result=False,
|
|
393
|
+
message=f"shell_executable must be an absolute path: {input_data.shell_executable}",
|
|
394
|
+
)
|
|
395
|
+
# Must exist and be a file
|
|
396
|
+
if not shell_path.exists():
|
|
397
|
+
return ValidationResult(
|
|
398
|
+
result=False,
|
|
399
|
+
message=f"shell_executable not found: {input_data.shell_executable}",
|
|
400
|
+
)
|
|
401
|
+
if not shell_path.is_file():
|
|
402
|
+
return ValidationResult(
|
|
403
|
+
result=False,
|
|
404
|
+
message=f"shell_executable is not a file: {input_data.shell_executable}",
|
|
405
|
+
)
|
|
406
|
+
# Must be executable
|
|
407
|
+
if not os.access(shell_path, os.X_OK):
|
|
408
|
+
return ValidationResult(
|
|
409
|
+
result=False,
|
|
410
|
+
message=f"shell_executable is not executable: {input_data.shell_executable}",
|
|
411
|
+
)
|
|
412
|
+
# Must be in a safe system directory or match known shell patterns
|
|
413
|
+
safe_dirs = {"/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"}
|
|
414
|
+
shell_name = shell_path.name.lower()
|
|
415
|
+
known_shells = {"bash", "sh", "zsh", "fish", "dash", "ksh", "tcsh", "csh"}
|
|
416
|
+
parent_dir = str(shell_path.parent)
|
|
417
|
+
if parent_dir not in safe_dirs and shell_name not in known_shells:
|
|
418
|
+
return ValidationResult(
|
|
419
|
+
result=False,
|
|
420
|
+
message=f"shell_executable must be a known shell in a standard location: {input_data.shell_executable}",
|
|
421
|
+
)
|
|
422
|
+
|
|
387
423
|
# Note: Path validation for sensitive directories (cd/find to /usr, /etc, etc.)
|
|
388
424
|
# is now handled in check_permissions() to allow user confirmation for read-only ops.
|
|
389
425
|
|
|
@@ -396,15 +432,9 @@ build projects, run tests, and interact with the file system."""
|
|
|
396
432
|
result=False, message="This command cannot be run in background"
|
|
397
433
|
)
|
|
398
434
|
|
|
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
|
-
|
|
435
|
+
# Note: Security validation (shell metacharacters, destructive commands, etc.)
|
|
436
|
+
# is handled in check_permissions() via evaluate_shell_command_permissions().
|
|
437
|
+
# validate_input() should only perform format/parameter validation.
|
|
408
438
|
return ValidationResult(result=True)
|
|
409
439
|
|
|
410
440
|
def render_result_for_assistant(self, output: BashToolOutput) -> str:
|
|
@@ -568,6 +598,7 @@ build projects, run tests, and interact with the file system."""
|
|
|
568
598
|
sandbox_requested: bool,
|
|
569
599
|
start_time: float,
|
|
570
600
|
input_data: BashToolInput,
|
|
601
|
+
context: Optional[ToolUseContext] = None,
|
|
571
602
|
) -> Optional[BashToolOutput]:
|
|
572
603
|
"""Run a command in background mode.
|
|
573
604
|
|
|
@@ -593,8 +624,50 @@ build projects, run tests, and interact with the file system."""
|
|
|
593
624
|
if input_data.timeout is None
|
|
594
625
|
else (timeout_seconds if timeout_seconds > 0 else None)
|
|
595
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
|
+
|
|
596
666
|
task_id = await start_background_command(
|
|
597
|
-
final_command,
|
|
667
|
+
final_command,
|
|
668
|
+
timeout=bg_timeout,
|
|
669
|
+
shell_executable=resolved_shell,
|
|
670
|
+
completion_callbacks=completion_callbacks, # type: ignore[arg-type]
|
|
598
671
|
)
|
|
599
672
|
|
|
600
673
|
return BashToolOutput(
|
|
@@ -860,6 +933,7 @@ build projects, run tests, and interact with the file system."""
|
|
|
860
933
|
sandbox_requested,
|
|
861
934
|
start,
|
|
862
935
|
input_data,
|
|
936
|
+
context,
|
|
863
937
|
)
|
|
864
938
|
if output:
|
|
865
939
|
yield ToolResult(
|
|
@@ -992,35 +1066,56 @@ build projects, run tests, and interact with the file system."""
|
|
|
992
1066
|
finally:
|
|
993
1067
|
self._current_is_read_only = previous_read_only
|
|
994
1068
|
if sandbox_cleanup:
|
|
995
|
-
with contextlib.suppress(
|
|
1069
|
+
with contextlib.suppress(OSError, IOError, PermissionError):
|
|
996
1070
|
sandbox_cleanup()
|
|
997
1071
|
|
|
998
1072
|
async def _force_kill_process(
|
|
999
1073
|
self, process: asyncio.subprocess.Process, grace_seconds: float = KILL_GRACE_SECONDS
|
|
1000
1074
|
) -> None:
|
|
1001
|
-
"""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
|
+
"""
|
|
1002
1081
|
if process.returncode is not None:
|
|
1003
1082
|
return
|
|
1004
1083
|
|
|
1005
1084
|
def _terminate() -> None:
|
|
1006
|
-
if
|
|
1007
|
-
|
|
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()
|
|
1008
1095
|
else:
|
|
1009
1096
|
process.terminate()
|
|
1010
1097
|
|
|
1011
1098
|
def _kill() -> None:
|
|
1012
|
-
if
|
|
1013
|
-
|
|
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()
|
|
1014
1109
|
else:
|
|
1015
1110
|
process.kill()
|
|
1016
1111
|
|
|
1017
|
-
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1112
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
1018
1113
|
_terminate()
|
|
1019
1114
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
1020
1115
|
await asyncio.wait_for(process.wait(), timeout=grace_seconds)
|
|
1021
1116
|
return
|
|
1022
1117
|
|
|
1023
|
-
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
1118
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
1024
1119
|
_kill()
|
|
1025
1120
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
1026
1121
|
await asyncio.wait_for(process.wait(), timeout=grace_seconds)
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
Allows the AI to edit files by replacing text.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import contextlib
|
|
6
7
|
import os
|
|
8
|
+
import tempfile
|
|
7
9
|
from pathlib import Path
|
|
8
|
-
from typing import AsyncGenerator, List, Optional
|
|
10
|
+
from typing import AsyncGenerator, Generator, List, Optional, TextIO
|
|
9
11
|
from pydantic import BaseModel, Field
|
|
10
12
|
|
|
11
13
|
from ripperdoc.core.tool import (
|
|
@@ -17,12 +19,65 @@ from ripperdoc.core.tool import (
|
|
|
17
19
|
ValidationResult,
|
|
18
20
|
)
|
|
19
21
|
from ripperdoc.utils.log import get_logger
|
|
22
|
+
from ripperdoc.utils.platform import HAS_FCNTL
|
|
20
23
|
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
24
|
from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
25
|
+
from ripperdoc.tools.file_read_tool import detect_file_encoding
|
|
22
26
|
|
|
23
27
|
logger = get_logger()
|
|
24
28
|
|
|
25
29
|
|
|
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"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@contextlib.contextmanager
|
|
55
|
+
def _file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, None, None]:
|
|
56
|
+
"""Acquire a file lock, with fallback for systems without fcntl.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
file_handle: An open file handle to lock
|
|
60
|
+
exclusive: If True, acquire exclusive lock; otherwise shared lock
|
|
61
|
+
|
|
62
|
+
Yields:
|
|
63
|
+
None
|
|
64
|
+
"""
|
|
65
|
+
if not HAS_FCNTL:
|
|
66
|
+
# On Windows or systems without fcntl, skip locking
|
|
67
|
+
yield
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
import fcntl
|
|
71
|
+
|
|
72
|
+
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
73
|
+
try:
|
|
74
|
+
fcntl.flock(file_handle.fileno(), lock_type)
|
|
75
|
+
yield
|
|
76
|
+
finally:
|
|
77
|
+
with contextlib.suppress(OSError):
|
|
78
|
+
fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
|
|
79
|
+
|
|
80
|
+
|
|
26
81
|
class FileEditToolInput(BaseModel):
|
|
27
82
|
"""Input schema for FileEditTool."""
|
|
28
83
|
|
|
@@ -180,62 +235,185 @@ match exactly (including whitespace and indentation)."""
|
|
|
180
235
|
async def call(
|
|
181
236
|
self, input_data: FileEditToolInput, context: ToolUseContext
|
|
182
237
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
183
|
-
"""Edit the file."""
|
|
238
|
+
"""Edit the file with TOCTOU protection."""
|
|
239
|
+
|
|
240
|
+
abs_file_path = os.path.abspath(input_data.file_path)
|
|
241
|
+
file_state_cache = getattr(context, "file_state_cache", {})
|
|
242
|
+
file_snapshot = file_state_cache.get(abs_file_path)
|
|
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"
|
|
184
248
|
|
|
185
249
|
try:
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
250
|
+
# Open file with exclusive lock to prevent concurrent modifications
|
|
251
|
+
# Use r+ mode to get a file handle we can lock before reading
|
|
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
|
+
|
|
266
|
+
with _file_lock(f, exclusive=True):
|
|
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
|
|
304
|
+
|
|
305
|
+
# Read content while holding the lock
|
|
306
|
+
content = f.read()
|
|
307
|
+
|
|
308
|
+
# Check if old_string exists
|
|
309
|
+
if input_data.old_string not in content:
|
|
310
|
+
output = FileEditToolOutput(
|
|
311
|
+
file_path=input_data.file_path,
|
|
312
|
+
replacements_made=0,
|
|
313
|
+
success=False,
|
|
314
|
+
message=f"String not found in file: {input_data.file_path}",
|
|
315
|
+
)
|
|
316
|
+
yield ToolResult(
|
|
317
|
+
data=output,
|
|
318
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
319
|
+
)
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
# Count occurrences
|
|
323
|
+
occurrence_count = content.count(input_data.old_string)
|
|
324
|
+
|
|
325
|
+
# Check for ambiguity if not replace_all
|
|
326
|
+
if not input_data.replace_all and occurrence_count > 1:
|
|
327
|
+
output = FileEditToolOutput(
|
|
328
|
+
file_path=input_data.file_path,
|
|
329
|
+
replacements_made=0,
|
|
330
|
+
success=False,
|
|
331
|
+
message=f"String appears {occurrence_count} times in file. "
|
|
332
|
+
f"Either provide a unique string or use replace_all=true",
|
|
333
|
+
)
|
|
334
|
+
yield ToolResult(
|
|
335
|
+
data=output,
|
|
336
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
337
|
+
)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
# Perform replacement
|
|
341
|
+
if input_data.replace_all:
|
|
342
|
+
new_content = content.replace(input_data.old_string, input_data.new_string)
|
|
343
|
+
replacements = occurrence_count
|
|
344
|
+
else:
|
|
345
|
+
new_content = content.replace(
|
|
346
|
+
input_data.old_string, input_data.new_string, 1
|
|
347
|
+
)
|
|
348
|
+
replacements = 1
|
|
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
|
+
|
|
363
|
+
# Atomic write: write to temp file then rename
|
|
364
|
+
# This ensures the file is either fully written or not at all
|
|
365
|
+
file_dir = os.path.dirname(abs_file_path)
|
|
366
|
+
try:
|
|
367
|
+
# Create temp file in same directory to ensure same filesystem
|
|
368
|
+
fd, temp_path = tempfile.mkstemp(
|
|
369
|
+
dir=file_dir, prefix=".ripperdoc_edit_", suffix=".tmp"
|
|
370
|
+
)
|
|
371
|
+
try:
|
|
372
|
+
with os.fdopen(fd, "w", encoding=write_encoding) as temp_f:
|
|
373
|
+
temp_f.write(new_content)
|
|
374
|
+
# Preserve original file permissions
|
|
375
|
+
original_stat = os.fstat(f.fileno())
|
|
376
|
+
os.chmod(temp_path, original_stat.st_mode)
|
|
377
|
+
# Atomic replace (works on Unix, best-effort on Windows)
|
|
378
|
+
os.replace(temp_path, abs_file_path)
|
|
379
|
+
except Exception:
|
|
380
|
+
# Clean up temp file on failure
|
|
381
|
+
with contextlib.suppress(OSError):
|
|
382
|
+
os.unlink(temp_path)
|
|
383
|
+
raise
|
|
384
|
+
except OSError as atomic_error:
|
|
385
|
+
# Fallback to in-place write if atomic write fails
|
|
386
|
+
# (e.g., cross-filesystem issues)
|
|
387
|
+
# Re-verify file hasn't changed before fallback write (TOCTOU protection)
|
|
388
|
+
f.seek(0)
|
|
389
|
+
current_content = f.read()
|
|
390
|
+
if current_content != content:
|
|
391
|
+
output = FileEditToolOutput(
|
|
392
|
+
file_path=input_data.file_path,
|
|
393
|
+
replacements_made=0,
|
|
394
|
+
success=False,
|
|
395
|
+
message="File was modified during atomic write fallback. Please retry.",
|
|
396
|
+
)
|
|
397
|
+
yield ToolResult(
|
|
398
|
+
data=output,
|
|
399
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
f.seek(0)
|
|
403
|
+
f.truncate()
|
|
404
|
+
f.write(new_content)
|
|
405
|
+
logger.debug(
|
|
406
|
+
"[file_edit_tool] Atomic write failed, used fallback: %s",
|
|
407
|
+
atomic_error,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Record the new snapshot after successful edit
|
|
234
411
|
try:
|
|
235
412
|
record_snapshot(
|
|
236
413
|
abs_file_path,
|
|
237
414
|
new_content,
|
|
238
415
|
getattr(context, "file_state_cache", {}),
|
|
416
|
+
encoding=write_encoding,
|
|
239
417
|
)
|
|
240
418
|
except (OSError, IOError, RuntimeError) as exc:
|
|
241
419
|
logger.warning(
|