ot-cli 0.2.1__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.
- openterminal/__init__.py +3 -0
- openterminal/agent/__init__.py +39 -0
- openterminal/agent/backend.py +180 -0
- openterminal/agent/build.py +115 -0
- openterminal/agent/middleware.py +111 -0
- openterminal/agent/model.py +41 -0
- openterminal/agent/prompt.py +63 -0
- openterminal/agent/runner.py +340 -0
- openterminal/agent/sysprobe.py +164 -0
- openterminal/agent/token_est.py +49 -0
- openterminal/app.py +52 -0
- openterminal/approval.py +7 -0
- openterminal/backend.py +3 -0
- openterminal/banner.py +7 -0
- openterminal/cli.py +426 -0
- openterminal/cmdset.py +7 -0
- openterminal/config.py +322 -0
- openterminal/connections/__init__.py +20 -0
- openterminal/connections/db.py +240 -0
- openterminal/connections/models.py +289 -0
- openterminal/connections/secrets.py +68 -0
- openterminal/connections_db.py +6 -0
- openterminal/core/__init__.py +41 -0
- openterminal/core/ai_flow.py +781 -0
- openterminal/core/auth.py +499 -0
- openterminal/core/cmdset_runner.py +352 -0
- openterminal/core/exec_events.py +650 -0
- openterminal/core/history.py +79 -0
- openterminal/core/hook_gate.py +415 -0
- openterminal/core/messages.py +193 -0
- openterminal/core/nested_ssh.py +178 -0
- openterminal/core/pipeline.py +1187 -0
- openterminal/core/pump.py +190 -0
- openterminal/core/transcript.py +26 -0
- openterminal/frontend/__init__.py +21 -0
- openterminal/frontend/approval.py +40 -0
- openterminal/frontend/banner.py +40 -0
- openterminal/frontend/headless.py +290 -0
- openterminal/frontend/render.py +41 -0
- openterminal/frontend/taskview.py +184 -0
- openterminal/frontend/term.py +1417 -0
- openterminal/headless.py +7 -0
- openterminal/history_db.py +7 -0
- openterminal/local_pty.py +3 -0
- openterminal/local_win.py +3 -0
- openterminal/policy.py +362 -0
- openterminal/render.py +7 -0
- openterminal/secrets_store.py +5 -0
- openterminal/session/__init__.py +17 -0
- openterminal/session/base.py +328 -0
- openterminal/session/local_posix.py +184 -0
- openterminal/session/local_win.py +173 -0
- openterminal/session/ssh.py +321 -0
- openterminal/shell/__init__.py +17 -0
- openterminal/shell/cmdset.py +49 -0
- openterminal/shell/integration.py +1298 -0
- openterminal/shell_integration.py +7 -0
- openterminal/shell_session.py +8 -0
- openterminal/skills/__init__.py +45 -0
- openterminal/skills/markdown-tables/SKILL.md +247 -0
- openterminal/ssh_pty.py +6 -0
- openterminal/sysprobe.py +7 -0
- openterminal/taskview.py +7 -0
- openterminal/term_frontend.py +7 -0
- openterminal/token_est.py +3 -0
- openterminal/transcript.py +7 -0
- openterminal/web/__init__.py +1 -0
- openterminal/web/files_api.py +789 -0
- openterminal/web/frontend/index.html +37 -0
- openterminal/web/frontend/static/app.js +3632 -0
- openterminal/web/frontend/static/block_stream.js +274 -0
- openterminal/web/frontend/static/block_view.js +383 -0
- openterminal/web/frontend/static/files_panel.js +782 -0
- openterminal/web/frontend/static/md_lite.js +127 -0
- openterminal/web/frontend/static/modal.js +122 -0
- openterminal/web/frontend/static/pad_ledger.js +46 -0
- openterminal/web/frontend/static/style.css +915 -0
- openterminal/web/frontend/static/ui/ui.css +1 -0
- openterminal/web/frontend/static/ui/ui.js +6292 -0
- openterminal/web/frontend/vendor/addon-fit.js +2 -0
- openterminal/web/frontend/vendor/xterm.css +8 -0
- openterminal/web/frontend/vendor/xterm.js +8 -0
- openterminal/web/protocol.py +41 -0
- openterminal/web/server.py +322 -0
- openterminal/web/worker.py +62 -0
- ot_cli-0.2.1.dist-info/METADATA +439 -0
- ot_cli-0.2.1.dist-info/RECORD +91 -0
- ot_cli-0.2.1.dist-info/WHEEL +5 -0
- ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
- ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
- ot_cli-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""持久 shell 会话接口与哨兵捕获主循环(local / ssh 共用)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Callable, Protocol
|
|
10
|
+
|
|
11
|
+
BEGIN_MARKER = "__OT_BEGIN__"
|
|
12
|
+
END_MARKER = "__OT_END__"
|
|
13
|
+
_BEGIN_RE = re.compile(rb"__OT_BEGIN__\r?\n")
|
|
14
|
+
_END_RE = re.compile(rb"__OT_END__(-?\d+)__(.*)__\r?\n")
|
|
15
|
+
_ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)")
|
|
16
|
+
|
|
17
|
+
# 中文提示符(密码:/口令:)单独以 str 编码拼进 bytes 正则(bytes 字面量
|
|
18
|
+
# 不能含非 ASCII)
|
|
19
|
+
_PASSWORD_PROMPT_RE = re.compile(
|
|
20
|
+
rb"(\[sudo\] password for |'s password:|\([^)\r\n]{1,80}\)\s*password:"
|
|
21
|
+
rb"|^password:|" + "密码[::]|口令[::]".encode() + rb")",
|
|
22
|
+
re.IGNORECASE | re.MULTILINE,
|
|
23
|
+
)
|
|
24
|
+
_PASSWORD_NEEDING_PREFIXES = {"sudo", "su", "ssh", "scp", "rsync"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _needs_password_prompt(command: str) -> bool:
|
|
28
|
+
head = command.strip().split()[0] if command.strip() else ""
|
|
29
|
+
return os.path.basename(head) in _PASSWORD_NEEDING_PREFIXES
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CommandResult:
|
|
34
|
+
output: str
|
|
35
|
+
exit_code: int
|
|
36
|
+
truncated: bool
|
|
37
|
+
cwd: str
|
|
38
|
+
reconnected: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ShellSession(Protocol):
|
|
42
|
+
cwd: str
|
|
43
|
+
|
|
44
|
+
async def start(self) -> None: ...
|
|
45
|
+
async def run(
|
|
46
|
+
self,
|
|
47
|
+
command: str,
|
|
48
|
+
*,
|
|
49
|
+
timeout: int | None = None,
|
|
50
|
+
on_output: Callable[[str], None] | None = None,
|
|
51
|
+
) -> CommandResult: ...
|
|
52
|
+
async def send_raw(self, data: bytes) -> None: ...
|
|
53
|
+
async def resize(self, rows: int, cols: int) -> None: ...
|
|
54
|
+
async def close(self) -> None: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def wrap_command(command: str) -> str:
|
|
58
|
+
"""把用户命令包进 BEGIN/END 哨兵的单行复合命令。
|
|
59
|
+
|
|
60
|
+
必须是单行:多行输入可能被会读 stdin 的命令(如 cat)偷吃掉。
|
|
61
|
+
"""
|
|
62
|
+
one_line = command.replace("\n", " ; ").replace("\r", " ")
|
|
63
|
+
return (
|
|
64
|
+
f"printf '{BEGIN_MARKER}\\n'; {one_line}; "
|
|
65
|
+
f"printf '{END_MARKER}%s__%s__\\n' \"$?\" \"$PWD\"\n"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def wrap_passthrough(command: str, marker: str) -> str:
|
|
70
|
+
"""交互式内联直通的单行包装:命令 ; printf 随机哨兵(带退出码)。
|
|
71
|
+
|
|
72
|
+
与 wrap_command 的差别:不打 BEGIN 标记(活块从命令回显起显示),哨兵用
|
|
73
|
+
调用方给的随机 marker。sudo su - / vim 等退出后 shell 会接着执行 printf,
|
|
74
|
+
worker 在字节流里扫描它即可自动检测「交互命令已结束」并取到退出码。
|
|
75
|
+
"""
|
|
76
|
+
one_line = command.replace("\n", " ; ").replace("\r", " ")
|
|
77
|
+
return f"{one_line} ; printf '{marker}_%s__\\n' \"$?\"\r"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _clean(raw: bytes) -> str:
|
|
81
|
+
raw = _ANSI_RE.sub(b"", raw)
|
|
82
|
+
return raw.decode("utf-8", errors="replace").replace("\r", "").rstrip("\n")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def slice_capture(data: bytes) -> tuple[str, int, str] | None:
|
|
86
|
+
"""从 PTY 字节流里切出(命令输出, exit_code, cwd);哨兵未到齐返回 None。"""
|
|
87
|
+
# 必须锚定"标记 + 真实换行":tty 回显的命令行里也含字面 __OT_BEGIN__\n,
|
|
88
|
+
# 但那是引号内的反斜杠+n 两字符,后面并非真正的 CR/LF(真实 printf 输出
|
|
89
|
+
# 在 cooked PTY 上是 __OT_BEGIN__\r\n)。
|
|
90
|
+
m_begin = _BEGIN_RE.search(data)
|
|
91
|
+
if not m_begin:
|
|
92
|
+
return None
|
|
93
|
+
body_start = m_begin.end()
|
|
94
|
+
end_idx = data.find(END_MARKER.encode(), body_start)
|
|
95
|
+
if end_idx < 0:
|
|
96
|
+
return None
|
|
97
|
+
line_end = data.find(b"\n", end_idx)
|
|
98
|
+
# 切片右端是排他的,必须把行尾 \n 包进窗口,否则要求 \r?\n 收尾的
|
|
99
|
+
# _END_RE 永不匹配。
|
|
100
|
+
search_end = line_end + 1 if line_end > 0 else len(data)
|
|
101
|
+
m = _END_RE.search(data[end_idx:search_end])
|
|
102
|
+
if not m:
|
|
103
|
+
return None
|
|
104
|
+
output = _clean(data[body_start:end_idx])
|
|
105
|
+
cwd = m.group(2).decode("utf-8", errors="replace")
|
|
106
|
+
return output, int(m.group(1)), cwd
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def truncate_output(text: str, limit: int) -> tuple[str, bool]:
|
|
110
|
+
if len(text) <= limit:
|
|
111
|
+
return text, False
|
|
112
|
+
head = text[: limit // 2]
|
|
113
|
+
tail = text[-limit // 2 :]
|
|
114
|
+
return f"{head}\n\n... 输出已截断(中间省略)...\n{tail}", True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class BasePtySession:
|
|
118
|
+
"""子类提供字节级读写,本类实现哨兵主循环、超时、截断、断线恢复。"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, *, default_timeout: int = 120, max_output_bytes: int = 102_400):
|
|
121
|
+
self.cwd = ""
|
|
122
|
+
self._buf = bytearray()
|
|
123
|
+
self._default_timeout = default_timeout
|
|
124
|
+
self._max_output = max_output_bytes
|
|
125
|
+
# 一个 PTY 就是一条交互式 shell:模型一轮并行发起多个 execute 时,
|
|
126
|
+
# langgraph 会并发调用 run(),而哨兵捕获/cwd 跟踪都要求命令严格串行,
|
|
127
|
+
# 否则两条命令的写入与回读互相截胡(命令拼坏、输出互吞、超时重置)。
|
|
128
|
+
self._run_lock = asyncio.Lock()
|
|
129
|
+
# 断线自动重连成功后的回调(async def () -> None),worker 用于把
|
|
130
|
+
# 历史命令重新注入新 shell;None = 无操作。
|
|
131
|
+
self.on_reconnect = None
|
|
132
|
+
|
|
133
|
+
def _wrap(self, command: str) -> str:
|
|
134
|
+
"""把命令包进哨兵;POSIX shell 语法,PowerShell 会话覆写此方法。"""
|
|
135
|
+
return wrap_command(command)
|
|
136
|
+
|
|
137
|
+
def _wrap_passthrough(self, command: str, marker: str) -> str:
|
|
138
|
+
"""交互式内联直通包装;PowerShell 会话覆写此方法。"""
|
|
139
|
+
return wrap_passthrough(command, marker)
|
|
140
|
+
|
|
141
|
+
# --- 子类实现 ---
|
|
142
|
+
async def _write_raw(self, data: bytes) -> None:
|
|
143
|
+
raise NotImplementedError
|
|
144
|
+
|
|
145
|
+
async def drain_to_silence(self, quiet: float = 0.2) -> None:
|
|
146
|
+
"""排空积压输出直到静默(默认无操作,本地会话覆写)。
|
|
147
|
+
|
|
148
|
+
只在**哨兵批写入前**调用:本地 tty 回显/重绘会把哨兵锚点搞乱
|
|
149
|
+
(zle cooked 模式整行回显、ConPTY 提示符重绘)。交互按键
|
|
150
|
+
(send_raw)绝不能排空——那会与 _pump_loop 抢读、吞掉提示符/
|
|
151
|
+
OSC 标记字节,还给每次按键加固定 quiet 延迟。
|
|
152
|
+
"""
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
async def _read_some(self, idle_timeout: float) -> bytes:
|
|
156
|
+
raise NotImplementedError
|
|
157
|
+
|
|
158
|
+
async def _interrupt(self) -> None:
|
|
159
|
+
raise NotImplementedError
|
|
160
|
+
|
|
161
|
+
async def recover(self) -> bool:
|
|
162
|
+
"""外部(worker 键盘路径)触发的断线恢复;成功时已回调 on_reconnect。"""
|
|
163
|
+
return await self._recover_connection()
|
|
164
|
+
|
|
165
|
+
async def _recover_connection(self) -> bool:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
async def _notify_reconnect(self) -> None:
|
|
169
|
+
"""重连成功后通知 worker(历史注入等);回调异常不外泄。"""
|
|
170
|
+
cb = self.on_reconnect
|
|
171
|
+
if cb is None:
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
await cb()
|
|
175
|
+
except Exception: # noqa: BLE001 - 钩子失败不能打断恢复流程
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
async def _still_alive(self) -> bool:
|
|
179
|
+
"""超时中断后哨兵缺失时的存活探测;默认不探测(视为已死)。
|
|
180
|
+
|
|
181
|
+
PowerShell 的 Ctrl-C 会中止整行提交、finally 里的 END 哨兵也不
|
|
182
|
+
输出,但 shell 本身还活着——ConPTY 会话覆写此方法保住会话状态。
|
|
183
|
+
"""
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
# --- 主循环 ---
|
|
187
|
+
async def run(
|
|
188
|
+
self,
|
|
189
|
+
command: str,
|
|
190
|
+
*,
|
|
191
|
+
timeout: int | None = None,
|
|
192
|
+
on_output: Callable[[str], None] | None = None,
|
|
193
|
+
) -> CommandResult:
|
|
194
|
+
async with self._run_lock:
|
|
195
|
+
return await self._run_locked(
|
|
196
|
+
command, timeout=timeout, on_output=on_output)
|
|
197
|
+
|
|
198
|
+
async def _run_locked(
|
|
199
|
+
self,
|
|
200
|
+
command: str,
|
|
201
|
+
*,
|
|
202
|
+
timeout: int | None = None,
|
|
203
|
+
on_output: Callable[[str], None] | None = None,
|
|
204
|
+
) -> CommandResult:
|
|
205
|
+
effective = timeout or self._default_timeout
|
|
206
|
+
self._buf.clear()
|
|
207
|
+
tee_started = False
|
|
208
|
+
deadline = asyncio.get_running_loop().time() + effective
|
|
209
|
+
try:
|
|
210
|
+
# 哨兵批写入前排空到静默(本地会话:防回显/重绘错位锚点;
|
|
211
|
+
# SSH 无此问题,基类默认 no-op)。交互按键不经过这里。
|
|
212
|
+
await self.drain_to_silence()
|
|
213
|
+
await self._write_raw(self._wrap(command).encode())
|
|
214
|
+
except (ConnectionError, EOFError):
|
|
215
|
+
if await self._recover_connection():
|
|
216
|
+
return self._reconnected_result()
|
|
217
|
+
raise
|
|
218
|
+
|
|
219
|
+
while True:
|
|
220
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
221
|
+
if remaining <= 0:
|
|
222
|
+
return await self._handle_timeout()
|
|
223
|
+
try:
|
|
224
|
+
chunk = await self._read_some(min(5.0, remaining))
|
|
225
|
+
except asyncio.TimeoutError:
|
|
226
|
+
continue
|
|
227
|
+
except (ConnectionError, EOFError):
|
|
228
|
+
if await self._recover_connection():
|
|
229
|
+
return self._reconnected_result()
|
|
230
|
+
raise
|
|
231
|
+
if not chunk:
|
|
232
|
+
if await self._recover_connection():
|
|
233
|
+
return self._reconnected_result()
|
|
234
|
+
raise ConnectionError("shell 通道已关闭")
|
|
235
|
+
|
|
236
|
+
prev_len = len(self._buf)
|
|
237
|
+
self._buf.extend(chunk)
|
|
238
|
+
if on_output:
|
|
239
|
+
# 实时回显:从真实 BEGIN 标记行起、到 END 止,原样 tee(保留颜色)。
|
|
240
|
+
# 用 _BEGIN_RE(标记+真实换行)锚定,跳过命令行 tty 回显。
|
|
241
|
+
if not tee_started:
|
|
242
|
+
m_begin = _BEGIN_RE.search(self._buf)
|
|
243
|
+
if m_begin:
|
|
244
|
+
tee_started = True
|
|
245
|
+
head = bytes(self._buf[m_begin.end() :])
|
|
246
|
+
if END_MARKER.encode() in head:
|
|
247
|
+
head = head[: head.find(END_MARKER.encode())]
|
|
248
|
+
if head:
|
|
249
|
+
on_output(head.decode("utf-8", errors="replace"))
|
|
250
|
+
else:
|
|
251
|
+
view = chunk
|
|
252
|
+
if END_MARKER.encode() in view:
|
|
253
|
+
view = view[: view.find(END_MARKER.encode())]
|
|
254
|
+
if view:
|
|
255
|
+
on_output(view.decode("utf-8", errors="replace"))
|
|
256
|
+
|
|
257
|
+
sliced = slice_capture(bytes(self._buf))
|
|
258
|
+
if sliced is not None:
|
|
259
|
+
output, code, cwd = sliced
|
|
260
|
+
output, truncated = truncate_output(output, self._max_output)
|
|
261
|
+
if not output:
|
|
262
|
+
output = "<no output>"
|
|
263
|
+
self.cwd = cwd or self.cwd
|
|
264
|
+
return CommandResult(output=output, exit_code=code,
|
|
265
|
+
truncated=truncated, cwd=self.cwd)
|
|
266
|
+
|
|
267
|
+
# 兜底:sudo/su/ssh 出现密码提示 → 提前中断,别等 120s 超时丢会话。
|
|
268
|
+
# 仅当提示串位于缓冲末尾(真实提示阻塞等待输入、其后无换行)才触发,
|
|
269
|
+
# 避免误杀输出里恰好含 password 字样的正常命令。
|
|
270
|
+
m = _PASSWORD_PROMPT_RE.search(self._buf)
|
|
271
|
+
if m and b"\n" not in self._buf[m.end():] and _needs_password_prompt(command):
|
|
272
|
+
await self._interrupt()
|
|
273
|
+
hint = (
|
|
274
|
+
"检测到密码/凭据提示:该命令需要交互输入密码,命令模式无法完成。\n"
|
|
275
|
+
"请按 Ctrl+R 进入终端透传模式手动执行(Web 端切换透传模式),"
|
|
276
|
+
"或先执行 sudo -v 刷新凭据后再试。"
|
|
277
|
+
)
|
|
278
|
+
if on_output is not None:
|
|
279
|
+
on_output(hint + "\n")
|
|
280
|
+
return CommandResult(
|
|
281
|
+
output=hint, exit_code=130, truncated=False, cwd=self.cwd,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# 防止异常输出无限占内存:保留尾部,哨兵在尾部 64KB 内必能再次出现
|
|
285
|
+
if len(self._buf) > max(self._max_output * 4, 1_000_000):
|
|
286
|
+
del self._buf[: len(self._buf) - 262_144]
|
|
287
|
+
|
|
288
|
+
def _reconnected_result(self) -> CommandResult:
|
|
289
|
+
return CommandResult(
|
|
290
|
+
output="[ssh] 连接中断后已自动重连;为避免非幂等重复执行,原命令未重跑,"
|
|
291
|
+
"请确认后重试。",
|
|
292
|
+
exit_code=1,
|
|
293
|
+
truncated=False,
|
|
294
|
+
cwd=self.cwd,
|
|
295
|
+
reconnected=True,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
async def _handle_timeout(self) -> CommandResult:
|
|
299
|
+
# 先 Ctrl-C 给命令优雅退出的机会,再等 3 秒哨兵
|
|
300
|
+
await self._interrupt()
|
|
301
|
+
loop = asyncio.get_running_loop()
|
|
302
|
+
deadline = loop.time() + 3
|
|
303
|
+
while loop.time() < deadline:
|
|
304
|
+
try:
|
|
305
|
+
self._buf.extend(await self._read_some(deadline - loop.time()))
|
|
306
|
+
except asyncio.TimeoutError:
|
|
307
|
+
break
|
|
308
|
+
except (ConnectionError, EOFError):
|
|
309
|
+
break
|
|
310
|
+
if slice_capture(bytes(self._buf)) is not None:
|
|
311
|
+
output, code, cwd = slice_capture(bytes(self._buf)) # type: ignore[misc]
|
|
312
|
+
self.cwd = cwd or self.cwd
|
|
313
|
+
return CommandResult(
|
|
314
|
+
output=f"{output}\n\n[命令在 {self._default_timeout}s 后被中断 (Ctrl-C)",
|
|
315
|
+
exit_code=130, truncated=False, cwd=self.cwd,
|
|
316
|
+
)
|
|
317
|
+
if await self._still_alive():
|
|
318
|
+
return CommandResult(
|
|
319
|
+
output=f"[命令在 {self._default_timeout}s 后被中断 (Ctrl-C),"
|
|
320
|
+
"shell 仍存活,会话状态保留。]",
|
|
321
|
+
exit_code=130, truncated=False, cwd=self.cwd,
|
|
322
|
+
)
|
|
323
|
+
if await self._recover_connection():
|
|
324
|
+
return self._reconnected_result()
|
|
325
|
+
return CommandResult(
|
|
326
|
+
output=f"Error: 命令超时({self._default_timeout}s),shell 已重置,会话状态丢失。",
|
|
327
|
+
exit_code=124, truncated=False, cwd=self.cwd,
|
|
328
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""macOS/Linux 本地持久 shell:openpty + 非阻塞读取。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fcntl
|
|
7
|
+
import os
|
|
8
|
+
import pty
|
|
9
|
+
import signal
|
|
10
|
+
import struct
|
|
11
|
+
import termios
|
|
12
|
+
|
|
13
|
+
from .base import BasePtySession, CommandResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _stall_dbg(n: int, first: bool = False) -> None:
|
|
17
|
+
"""临时排障:写入 EAGAIN 停滞计数(OT_WEB_DEBUG=1 时可见)。"""
|
|
18
|
+
import os as _os
|
|
19
|
+
if _os.environ.get("OT_WEB_DEBUG"):
|
|
20
|
+
import sys
|
|
21
|
+
print(f"[otdbg] write stalled x{n}{'' if first else ' (recovered)'}",
|
|
22
|
+
flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalPtySession(BasePtySession):
|
|
26
|
+
def __init__(self, shell: str | None = None, **kwargs) -> None:
|
|
27
|
+
super().__init__(**kwargs)
|
|
28
|
+
self._shell = shell or os.environ.get("SHELL", "/bin/bash")
|
|
29
|
+
self._pid: int | None = None
|
|
30
|
+
self._fd: int | None = None
|
|
31
|
+
# 写入停顿期的输出接收器(asyncio 单线程,同步回调):setup 注入期
|
|
32
|
+
# 由 worker 设为喂 StreamRouter,打破回显塞满输出队列的死锁
|
|
33
|
+
self._write_sink = None
|
|
34
|
+
|
|
35
|
+
async def start(self) -> None:
|
|
36
|
+
pid, fd = pty.fork()
|
|
37
|
+
if pid == 0: # 子进程
|
|
38
|
+
try:
|
|
39
|
+
env = os.environ.copy()
|
|
40
|
+
env["TERM"] = "xterm-256color"
|
|
41
|
+
os.execve(self._shell, [self._shell, "-l"], env)
|
|
42
|
+
except Exception:
|
|
43
|
+
os._exit(127)
|
|
44
|
+
self._pid, self._fd = pid, fd
|
|
45
|
+
# 壳继承进程 CWD(pty.fork 不 chdir):文件根 _local_root 在构造时读
|
|
46
|
+
# session.cwd,这里不填就会空着回退主目录——用户/测试的相对文件全踩空
|
|
47
|
+
self.cwd = os.getcwd()
|
|
48
|
+
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
49
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
50
|
+
await self.resize(40, 120)
|
|
51
|
+
# 等初始提示符输出排空
|
|
52
|
+
await self._quiet()
|
|
53
|
+
|
|
54
|
+
async def _quiet(self) -> None:
|
|
55
|
+
# 偏离计划(最小):必须先等到 shell 的首批输出,再做静默排空。
|
|
56
|
+
# 本机登录 zsh 加载 rc 时约 2.4s 内无任何输出;原实现首次 0.5s
|
|
57
|
+
# 超时即返回,会让命令在行编辑器就绪前写入,zsh 随后重绘该行,
|
|
58
|
+
# 重绘文本里的字面哨兵会把 slice_capture 的锚点永久锁死。
|
|
59
|
+
try:
|
|
60
|
+
if not await self._read_some(5.0):
|
|
61
|
+
return
|
|
62
|
+
while True:
|
|
63
|
+
await self._read_some(0.5)
|
|
64
|
+
except (asyncio.TimeoutError, OSError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
async def _write_raw(self, data: bytes) -> None:
|
|
68
|
+
assert self._fd is not None
|
|
69
|
+
# 直写,不排空:交互按键(send_raw)走这里,排空会与主循环读者
|
|
70
|
+
# 抢 fd 数据、吞掉提示符/OSC 标记字节,还引入固定 200ms 延迟。
|
|
71
|
+
# fd 是 O_NONBLOCK(读泵需要):tty 输入队列满(TTYHOG)时 write
|
|
72
|
+
# 抛 BlockingIOError,须等待消费者(shell 行编辑器)排空后重试。
|
|
73
|
+
# 死锁防护:若写方回显已塞满输出队列(泵未启动的 setup 注入期),
|
|
74
|
+
# 行规程因 echo 无处写而拒绝接受输入 → 双向永久 EAGAIN。此时
|
|
75
|
+
# _write_sink(setup 注入期设为喂 router)边写边读输出,打破死锁;
|
|
76
|
+
# 无 sink 时丢弃——该输出本就无读者,不丢则死锁。
|
|
77
|
+
stalls = 0
|
|
78
|
+
while data:
|
|
79
|
+
try:
|
|
80
|
+
n = os.write(self._fd, data)
|
|
81
|
+
data = data[n:]
|
|
82
|
+
if stalls:
|
|
83
|
+
_stall_dbg(stalls)
|
|
84
|
+
stalls = 0
|
|
85
|
+
except BlockingIOError:
|
|
86
|
+
stalls += 1
|
|
87
|
+
if stalls == 100:
|
|
88
|
+
_stall_dbg(stalls, first=True)
|
|
89
|
+
got = b""
|
|
90
|
+
if self._write_sink is not None:
|
|
91
|
+
try:
|
|
92
|
+
got = os.read(self._fd, 65_536)
|
|
93
|
+
except (BlockingIOError, OSError):
|
|
94
|
+
got = b""
|
|
95
|
+
if got:
|
|
96
|
+
self._write_sink(got)
|
|
97
|
+
await asyncio.sleep(0.02)
|
|
98
|
+
|
|
99
|
+
async def drain_to_silence(self, quiet: float = 0.2) -> None:
|
|
100
|
+
# 哨兵批写入前:排空到静默。上一条命令的哨兵返回时,zsh 往往还没
|
|
101
|
+
# 画完新提示符,此刻 tty 处于 cooked 模式:写入会被行规范整行回显,
|
|
102
|
+
# 再被 zle 重绘,产生多份带真实换行的字面哨兵,使 slice_capture
|
|
103
|
+
# 锚点永久错位。等提示符画完(zle 就绪)再写。仅批路径调用。
|
|
104
|
+
while True:
|
|
105
|
+
try:
|
|
106
|
+
chunk = await self._read_some(quiet)
|
|
107
|
+
except (asyncio.TimeoutError, OSError):
|
|
108
|
+
break
|
|
109
|
+
if not chunk:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
async def _read_some(self, idle_timeout: float) -> bytes:
|
|
113
|
+
assert self._fd is not None
|
|
114
|
+
loop = asyncio.get_running_loop()
|
|
115
|
+
fut: asyncio.Future[bytes] = loop.create_future()
|
|
116
|
+
|
|
117
|
+
def _readable() -> None:
|
|
118
|
+
if fut.done():
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
data = os.read(self._fd, 65_536)
|
|
122
|
+
except BlockingIOError:
|
|
123
|
+
return
|
|
124
|
+
except OSError:
|
|
125
|
+
fut.set_result(b"")
|
|
126
|
+
return
|
|
127
|
+
fut.set_result(data)
|
|
128
|
+
|
|
129
|
+
loop.add_reader(self._fd, _readable)
|
|
130
|
+
try:
|
|
131
|
+
return await asyncio.wait_for(fut, timeout=idle_timeout)
|
|
132
|
+
finally:
|
|
133
|
+
loop.remove_reader(self._fd)
|
|
134
|
+
|
|
135
|
+
async def _interrupt(self) -> None:
|
|
136
|
+
if self._fd is not None:
|
|
137
|
+
os.write(self._fd, b"\x03") # Ctrl-C
|
|
138
|
+
|
|
139
|
+
async def _recover_connection(self) -> bool:
|
|
140
|
+
"""本地:关掉死掉的进程组并重启 shell,语义等同"重新连上"。"""
|
|
141
|
+
await self.close()
|
|
142
|
+
try:
|
|
143
|
+
await self.start()
|
|
144
|
+
await self._notify_reconnect()
|
|
145
|
+
return True
|
|
146
|
+
except OSError:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
async def _handle_timeout(self) -> CommandResult:
|
|
150
|
+
# 偏离计划(最小):Ctrl-C 后 zsh 会直接丢弃当前命令行、不打印哨兵,
|
|
151
|
+
# 基类于是重启 shell 并返回 reconnected(exit 1)。本地重启后会话
|
|
152
|
+
# 状态确已丢失,按超时语义返回 124(Task 4 测试约定 124/130)。
|
|
153
|
+
result = await super()._handle_timeout()
|
|
154
|
+
if result.reconnected:
|
|
155
|
+
return CommandResult(
|
|
156
|
+
output=f"Error: 命令超时({self._default_timeout}s),shell 已重置,会话状态丢失。",
|
|
157
|
+
exit_code=124, truncated=False, cwd=self.cwd,
|
|
158
|
+
)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
async def resize(self, rows: int, cols: int) -> None:
|
|
162
|
+
if self._fd is None:
|
|
163
|
+
return
|
|
164
|
+
size = struct.pack("HHHH", rows, cols, 0, 0)
|
|
165
|
+
fcntl.ioctl(self._fd, termios.TIOCSWINSZ, size)
|
|
166
|
+
|
|
167
|
+
async def send_raw(self, data: bytes) -> None:
|
|
168
|
+
await self._write_raw(data)
|
|
169
|
+
|
|
170
|
+
async def close(self) -> None:
|
|
171
|
+
if self._pid is not None:
|
|
172
|
+
try:
|
|
173
|
+
os.killpg(os.getpgid(self._pid), signal.SIGKILL)
|
|
174
|
+
except (ProcessLookupError, OSError):
|
|
175
|
+
try:
|
|
176
|
+
os.kill(self._pid, signal.SIGKILL)
|
|
177
|
+
except (ProcessLookupError, OSError):
|
|
178
|
+
pass
|
|
179
|
+
if self._fd is not None:
|
|
180
|
+
try:
|
|
181
|
+
os.close(self._fd)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
self._pid = self._fd = None
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Windows 本地持久 shell:ConPTY(pywinpty)+ 专职读者线程。
|
|
2
|
+
|
|
3
|
+
与 LocalPtySession 实现同一个 ShellSession 协议(BasePtySession 哨兵主循环
|
|
4
|
+
复用),差别只在字节层:ConPTY 没有非阻塞 fd,用线程阻塞读并经线程安全
|
|
5
|
+
队列投递给事件循环。命令包装用 PowerShell 语法覆写 _wrap。
|
|
6
|
+
|
|
7
|
+
仅 Windows 可导入;Linux/macOS 路径不经过本模块。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
from .base import BasePtySession
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WinPtySession(BasePtySession):
|
|
19
|
+
def __init__(self, shell: str | None = None, **kwargs) -> None:
|
|
20
|
+
super().__init__(**kwargs)
|
|
21
|
+
self._shell = shell or "powershell.exe -NoLogo"
|
|
22
|
+
self._proc = None
|
|
23
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
24
|
+
self._queue: asyncio.Queue[bytes] | None = None
|
|
25
|
+
self._reader: threading.Thread | None = None
|
|
26
|
+
|
|
27
|
+
# --- PowerShell 哨兵包装(与 wrap_command 的 POSIX 版对应)---
|
|
28
|
+
def _wrap(self, command: str) -> str:
|
|
29
|
+
one_line = command.replace("\n", " ; ").replace("\r", " ")
|
|
30
|
+
# 结尾必须 \r:PSReadLine 把 LF 当 Shift-Enter(编辑缓冲区换行、
|
|
31
|
+
# 出 >> 续行提示),命令不会被提交执行
|
|
32
|
+
# 退出码:先清 $LASTEXITCODE——原生命令码只在它非空时取(cmd /c
|
|
33
|
+
# exit 3 时 $? 也是 False,不能用它兜底);为空说明只跑了 cmdlet,
|
|
34
|
+
# 用 $? 判成败。try/finally 让正常路径的 END 一定输出
|
|
35
|
+
return (
|
|
36
|
+
"$LASTEXITCODE = $null; Write-Output '__OT_BEGIN__'; "
|
|
37
|
+
"try { " + one_line + " } finally { "
|
|
38
|
+
"$otc = if ($null -eq $LASTEXITCODE) { "
|
|
39
|
+
"if ($?) { 0 } else { 1 } } else { $LASTEXITCODE }; "
|
|
40
|
+
"Write-Output ('__OT_END__' + $otc + '__'"
|
|
41
|
+
" + (Get-Location).Path + '__') }\r"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def _wrap_passthrough(self, command: str, marker: str) -> str:
|
|
45
|
+
one_line = command.replace("\n", " ; ").replace("\r", " ")
|
|
46
|
+
# 与 POSIX 版一致:交互程序退出后输出带随机 marker 的哨兵;
|
|
47
|
+
# $LASTEXITCODE 为空说明只跑了 cmdlet/无原生命令退出码,按 0 处理
|
|
48
|
+
return (one_line + " ; Write-Output ('" + marker + "_' + "
|
|
49
|
+
"$(if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 })"
|
|
50
|
+
" + '__')\r")
|
|
51
|
+
|
|
52
|
+
# --- 生命周期 ---
|
|
53
|
+
async def start(self) -> None:
|
|
54
|
+
from winpty import PtyProcess
|
|
55
|
+
|
|
56
|
+
self._proc = PtyProcess.spawn(
|
|
57
|
+
self._shell.split(), dimensions=(40, 120))
|
|
58
|
+
# 同 local_posix:壳继承进程 CWD,文件根构造时读 session.cwd
|
|
59
|
+
self.cwd = os.getcwd()
|
|
60
|
+
self._loop = asyncio.get_running_loop()
|
|
61
|
+
self._queue = asyncio.Queue()
|
|
62
|
+
self._reader = threading.Thread(
|
|
63
|
+
target=self._pump, name="ot-conpty-reader", daemon=True)
|
|
64
|
+
self._reader.start()
|
|
65
|
+
await self._quiet() # 等 PowerShell 横幅/提示符排空
|
|
66
|
+
|
|
67
|
+
async def _quiet(self) -> None:
|
|
68
|
+
try:
|
|
69
|
+
if not await self._read_some(10.0):
|
|
70
|
+
return
|
|
71
|
+
while True:
|
|
72
|
+
await self._read_some(0.5)
|
|
73
|
+
except (asyncio.TimeoutError, OSError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def _pump(self) -> None:
|
|
77
|
+
"""专职读者线程:ConPTY 无非阻塞读,阻塞读并投递进事件循环。"""
|
|
78
|
+
assert self._loop is not None and self._queue is not None
|
|
79
|
+
while self._proc is not None:
|
|
80
|
+
try:
|
|
81
|
+
data = self._proc.read(65_536)
|
|
82
|
+
except (EOFError, OSError):
|
|
83
|
+
self._loop.call_soon_threadsafe(self._queue.put_nowait, b"")
|
|
84
|
+
return
|
|
85
|
+
if not data:
|
|
86
|
+
continue
|
|
87
|
+
payload = data if isinstance(data, bytes) else \
|
|
88
|
+
data.encode("utf-8", errors="replace")
|
|
89
|
+
self._loop.call_soon_threadsafe(self._queue.put_nowait, payload)
|
|
90
|
+
|
|
91
|
+
# --- 字节层 ---
|
|
92
|
+
async def _write_raw(self, data: bytes) -> None:
|
|
93
|
+
# 直写,不排空:交互按键(send_raw)走这里,排空会与 pump 的
|
|
94
|
+
# 读者线程抢读、吞掉提示符/OSC 标记字节,还引入固定 200ms 延迟。
|
|
95
|
+
# 哨兵批写入需要静默的场景由 drain_to_silence()(仅批路径调用)承担。
|
|
96
|
+
if self._proc is not None:
|
|
97
|
+
self._proc.write(data.decode("utf-8", errors="replace"))
|
|
98
|
+
|
|
99
|
+
async def drain_to_silence(self, quiet: float = 0.2) -> None:
|
|
100
|
+
# 哨兵批写入前:排空到静默,避免回显/重绘把哨兵锚点搞乱
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
chunk = await self._read_some(quiet)
|
|
104
|
+
except (asyncio.TimeoutError, OSError):
|
|
105
|
+
break
|
|
106
|
+
if not chunk:
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
async def _read_some(self, idle_timeout: float) -> bytes:
|
|
110
|
+
assert self._queue is not None
|
|
111
|
+
return await asyncio.wait_for(self._queue.get(), timeout=idle_timeout)
|
|
112
|
+
|
|
113
|
+
async def _interrupt(self) -> None:
|
|
114
|
+
if self._proc is not None:
|
|
115
|
+
try:
|
|
116
|
+
self._proc.sendintr() # Ctrl-C
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
async def _recover_connection(self) -> bool:
|
|
121
|
+
"""本地 shell 死掉:关掉重启,语义等同"重新连上"。"""
|
|
122
|
+
await self.close()
|
|
123
|
+
try:
|
|
124
|
+
await self.start()
|
|
125
|
+
await self._notify_reconnect()
|
|
126
|
+
return True
|
|
127
|
+
except OSError:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
async def _still_alive(self) -> bool:
|
|
131
|
+
"""超时 Ctrl-C 后 shell 通常还活着(PS 的中断只作废当前行,
|
|
132
|
+
finally 里的 END 哨兵不会输出)。发一条探针命令确认,避免整个
|
|
133
|
+
会话被无谓重启、cd 等状态丢失。"""
|
|
134
|
+
probe = "Write-Output ('ot-al' + 'ive-ok')\r"
|
|
135
|
+
try:
|
|
136
|
+
await self._write_raw(probe.encode())
|
|
137
|
+
except (ConnectionError, EOFError, OSError):
|
|
138
|
+
return False
|
|
139
|
+
loop = asyncio.get_running_loop()
|
|
140
|
+
deadline = loop.time() + 5
|
|
141
|
+
buf = b""
|
|
142
|
+
while loop.time() < deadline:
|
|
143
|
+
try:
|
|
144
|
+
chunk = await self._read_some(min(2.0, deadline - loop.time()))
|
|
145
|
+
except asyncio.TimeoutError:
|
|
146
|
+
continue # 高负载下回显可能迟于单次 2s 读:等满 5s 总预算
|
|
147
|
+
except (ConnectionError, EOFError, OSError):
|
|
148
|
+
return False
|
|
149
|
+
# 探针命令回显里是 'ot-al'+'ive-ok' 拆开的,只有真实输出才出现
|
|
150
|
+
# 连续的 ot-alive-ok;标记可能跨 chunk 拆分,累积后再判断
|
|
151
|
+
buf += chunk
|
|
152
|
+
if b"ot-alive-ok" in buf:
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
async def resize(self, rows: int, cols: int) -> None:
|
|
157
|
+
if self._proc is not None:
|
|
158
|
+
try:
|
|
159
|
+
self._proc.setwinsize(rows, cols)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
async def send_raw(self, data: bytes) -> None:
|
|
164
|
+
await self._write_raw(data)
|
|
165
|
+
|
|
166
|
+
async def close(self) -> None:
|
|
167
|
+
proc, self._proc = self._proc, None
|
|
168
|
+
if proc is not None:
|
|
169
|
+
try:
|
|
170
|
+
proc.terminate(force=True)
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
# 读者线程随 proc 关闭读到 EOFError 自行退出
|