-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexecutor.py
More file actions
219 lines (188 loc) · 6.89 KB
/
Copy pathexecutor.py
File metadata and controls
219 lines (188 loc) · 6.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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
"""Subprocess command execution with streaming output and per-chat cwd."""
from __future__ import annotations
import asyncio
import os
import re
import shlex
import time
from collections.abc import AsyncGenerator
from pathlib import Path
# Match `cd` as a shell word (start / after ;|& whitespace)
_CD_WORD = re.compile(r"(?:^|[\s;|&])cd\b", re.IGNORECASE)
def is_cd_command(command: str) -> str | None:
"""
If command is (or contains) a cd navigation, return the target path
("" means home). Otherwise return None.
Compound forms like `mkdir -p foo && cd foo` are treated as cd-only:
the mkdir portion is ignored and never executed.
"""
cmd = (command or "").strip()
if not cmd:
return None
lowered = cmd.lower()
is_compound = (
"&&" in cmd
or ";" in cmd
or "|" in cmd
or "mkdir" in lowered
)
if is_compound:
if not _CD_WORD.search(cmd):
return None
return _extract_cd_target(cmd)
try:
tokens = shlex.split(cmd)
except ValueError:
tokens = cmd.split()
if not tokens or tokens[0] != "cd":
return None
if len(tokens) == 1:
return ""
if len(tokens) == 2:
return tokens[1]
# Multi-arg pure cd — still take the first path only
return tokens[1]
def _extract_cd_target(cmd: str) -> str:
"""Extract path after the last `cd` word in a compound command."""
matches = list(_CD_WORD.finditer(cmd))
if not matches:
return ""
rest = cmd[matches[-1].end() :].strip()
if not rest or rest.startswith(("&&", ";", "|")):
return ""
try:
tokens = shlex.split(rest)
except ValueError:
tokens = rest.split()
if not tokens:
return ""
target = tokens[0]
if target in {"&&", ";", "|"}:
return ""
return target
class CommandExecutor:
def __init__(
self,
timeout_seconds: int = 300,
max_output_chars: int = 4000,
) -> None:
self.timeout_seconds = timeout_seconds
self.max_output_chars = max_output_chars
self.last_exit_code: int = 0
self._current_dir: dict[int, str] = {}
def get_current_dir(self, chat_id: int, default: str) -> str:
return self._current_dir.get(chat_id, default)
def change_directory(
self, chat_id: int, target: str, default: str
) -> str:
"""
Resolve and apply a cd target for this chat (no subprocess).
Returns a user-facing success or error message.
Never creates directories.
"""
current = self.get_current_dir(chat_id, default)
if target == "":
resolved = os.path.expanduser("~")
else:
expanded = os.path.expanduser(target)
if os.path.isabs(expanded):
resolved = expanded
else:
resolved = os.path.join(current, expanded)
try:
resolved = str(Path(resolved).resolve())
except (OSError, RuntimeError):
self.last_exit_code = 1
return f"Directory not found: {resolved}"
if not os.path.isdir(resolved):
self.last_exit_code = 1
return f"Directory not found: {resolved}"
self._current_dir[chat_id] = resolved
self.last_exit_code = 0
return f"📂 Now in {resolved}"
async def execute(
self, command: str, cwd: str
) -> AsyncGenerator[str, None]:
"""Run command in a shell; yield stdout/stderr lines. Never raises."""
# Defense in depth: cd / mkdir&&cd must never hit the shell
if is_cd_command(command) is not None:
self.last_exit_code = 1
yield (
"Error: directory changes are handled by localops, "
"not the shell. Refusing to execute."
)
return
started = time.monotonic()
total_chars = 0
truncated = False
process: asyncio.subprocess.Process | None = None
self.last_exit_code = 0
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd,
)
assert process.stdout is not None
async def _readline() -> bytes:
assert process is not None and process.stdout is not None
return await process.stdout.readline()
while True:
remaining = self.timeout_seconds - (time.monotonic() - started)
if remaining <= 0:
await self._kill(process)
self.last_exit_code = -1
yield (
f"\n⏱️ Timeout: command killed after "
f"{self.timeout_seconds}s"
)
return
try:
line_bytes = await asyncio.wait_for(
_readline(), timeout=remaining
)
except asyncio.TimeoutError:
await self._kill(process)
self.last_exit_code = -1
yield (
f"\n⏱️ Timeout: command killed after "
f"{self.timeout_seconds}s"
)
return
if not line_bytes:
break
line = line_bytes.decode("utf-8", errors="replace")
if truncated:
continue
if total_chars + len(line) > self.max_output_chars:
room = max(0, self.max_output_chars - total_chars)
if room:
yield line[:room]
total_chars += room
truncated = True
yield (
f"\n… truncated at {self.max_output_chars} characters"
)
continue
total_chars += len(line)
# Yield without forcing an extra newline if the line already has one
yield line.rstrip("\n") if line.endswith("\n") else line
returncode = await process.wait()
self.last_exit_code = int(returncode) if returncode is not None else 0
if self.last_exit_code != 0:
yield f"\n❌ Exit code {self.last_exit_code}"
except Exception as exc: # noqa: BLE001 — never raise to caller
self.last_exit_code = -1
yield f"\n❌ Error: {exc}"
finally:
if process is not None and process.returncode is None:
await self._kill(process)
async def _kill(self, process: asyncio.subprocess.Process) -> None:
try:
process.kill()
await process.wait()
except ProcessLookupError:
pass
except Exception: # noqa: BLE001
pass