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,499 @@
|
|
|
1
|
+
"""密码全链路状态机(Task 3 自 PipelineCore 原样搬入)。
|
|
2
|
+
|
|
3
|
+
密码提示符检测 → 模态代关 → 验证窗 hold/释放 → 手输捕获 →「记住」询问
|
|
4
|
+
→ 凭据库写入,整条链的判定与定时器都在本组件。状态**物理存 core 实例**
|
|
5
|
+
(P3 裁定:pump/命令集/_on_keys 直读 core._pw_*,_bare_core 测试在 core
|
|
6
|
+
上预置状态;AuthFlow.__init__ 以 hasattr 守卫补建,预置值不覆盖),
|
|
7
|
+
方法体经 self.core 读写。monkeypatch 缝隙(store_password/load_password/
|
|
8
|
+
别名读取)经 _impl 模块属性运行期解析——对 openterminal.core 的补丁经
|
|
9
|
+
补丁桥落到 _core_impl,这里调用时才取值。
|
|
10
|
+
|
|
11
|
+
组件公开接口:inspect_output(泵责任链②)、feed_input(按键 hold/捕获)、
|
|
12
|
+
armed(提示符触发门槛)、ask_password / store_auth_password(认证回调)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from . import pipeline as _impl
|
|
22
|
+
from ..shell.integration import is_internal_line
|
|
23
|
+
from ..session.base import _PASSWORD_PROMPT_RE, _needs_password_prompt
|
|
24
|
+
from .messages import PasswordCancelled, ServerMsg, _dbg
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthFlow:
|
|
28
|
+
def __init__(self, core) -> None:
|
|
29
|
+
self.core = core
|
|
30
|
+
# 嵌套密码提示符状态机:ssh/sudo/scp/rsync 在 PTY 前台讨密码时
|
|
31
|
+
# (典型:预置命令 `ssh 跳板机` 后的登录),①自然语言拦截链必须
|
|
32
|
+
# 透传回车(密码被误判成自然语言 = 登录被 ^C 掉);②可弹模态收
|
|
33
|
+
# 密码并按目标主机写入凭据库,下次自动填充
|
|
34
|
+
for name, val in (
|
|
35
|
+
("_pw_cancelled", False), # 内联密码输入的 Ctrl+C 标记
|
|
36
|
+
("_pw_last_entered", None), # 内联手输密码留档(password auth)
|
|
37
|
+
("_pw_tail", bytearray()), # 输出滚动尾窗(提示符匹配)
|
|
38
|
+
("_pw_prompt_seen", False), # 尾窗以密码提示符结尾(已触发应答)
|
|
39
|
+
("_nested_pw_attempts", 0), # 当前嵌套命令已应答次数(0 允许自动填充)
|
|
40
|
+
("_nested_pw_ctx", None), # 当前应答的凭据键 (host, user, port)
|
|
41
|
+
("_pw_verifying", False), # 验证窗口:密码已打进 PTY、结果未到
|
|
42
|
+
("_pw_verify_at", 0.0),
|
|
43
|
+
("_pw_verify_timer", None), # 验证窗口强制到期(不依赖输出驱动)
|
|
44
|
+
("_pw_hold", []), # hold 项 ("raw", bytes)/("submit", t, dirty)
|
|
45
|
+
("_pw_hold_timer", None), # hold 释放防抖任务(_PW_HOLD_RELEASE)
|
|
46
|
+
("_pw_at_end", False), # 上一 pump 块尾窗是否以密码提示符结尾
|
|
47
|
+
("_pw_modal_open", False), # 嵌套密码模态在屏等应答
|
|
48
|
+
("_pw_dismiss_timer", None), # 模态代关防抖任务(_PW_MODAL_DISMISS)
|
|
49
|
+
("_pw_capture_key", None), # 手输捕获的凭据键(sudo@<主机> 专用键)
|
|
50
|
+
("_pw_typed", bytearray()), # 捕获中的密码字节(回车即冻结)
|
|
51
|
+
("_pw_remember_candidate", None), # 已提交待验证的 (键, 密码)
|
|
52
|
+
("_pw_remember_pending", None), # 验证成功待用户应答的 (键, 密码)
|
|
53
|
+
("_pw_result_from", 0), # 验证窗起点在 _pw_tail 的偏移
|
|
54
|
+
):
|
|
55
|
+
if not hasattr(core, name):
|
|
56
|
+
setattr(core, name, val)
|
|
57
|
+
|
|
58
|
+
# --- 责任链入口 ---
|
|
59
|
+
|
|
60
|
+
def inspect_output(self, data: bytes) -> bytes:
|
|
61
|
+
"""泵责任链阶段②入口:检测提示符/模态代关/验证窗判定。
|
|
62
|
+
只观测不改字节,透传返回。"""
|
|
63
|
+
self._detect_password_prompt(data)
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
def feed_input(self, data: bytes) -> bool:
|
|
67
|
+
"""键盘字节经手输密码旁路留档;验证窗内且非捕获态时 hold 抢跑输入。
|
|
68
|
+
|
|
69
|
+
返回 True 表示本字节已被拦(core._on_keys 不再透传 PTY)。
|
|
70
|
+
capturing 必须取**进块前**快照:块内回车会冻结候选并开启验证窗,
|
|
71
|
+
若按事后状态判 hold,密码自己就被 hold 住发不出去。"""
|
|
72
|
+
capturing = self.core._pw_capture_key is not None
|
|
73
|
+
self._pw_typed_capture(data)
|
|
74
|
+
if self.core._pw_verifying and not capturing:
|
|
75
|
+
# 验证窗口(密码已提交、auth 结果未到):按键 hold,验证结束
|
|
76
|
+
# 依序释放——否则落进 ssh 输入队列被当下一条密码吃掉(真机
|
|
77
|
+
# 「模态刚关抢跑的命令连排 Permission denied」根因)。
|
|
78
|
+
# 例外:密码提示符又在前台(错密重讨、捕获已武装)——按键
|
|
79
|
+
# 是给提示符的应答,hold 会让重试到超时才迟到远端
|
|
80
|
+
self.core._pw_hold.append(("raw", data))
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def _detect_password_prompt(self, data: bytes) -> None:
|
|
85
|
+
"""在输出滚动尾窗上做密码提示符检测(每 pump 块一次)。
|
|
86
|
+
|
|
87
|
+
触发条件足够收敛,避免命令输出里的字面 "password:" 误触:
|
|
88
|
+
① 尾窗**结尾**(±4 字符容差)命中 _PASSWORD_PROMPT_RE——讨密码时
|
|
89
|
+
提示符就是屏幕上最后的东西;提示符被后续输出冲走即重新武装;
|
|
90
|
+
② 当前最内层未闭合的用户命令帧是 ssh/sudo/scp/rsync 类(
|
|
91
|
+
_needs_password_prompt)——grep 密码文件等输出再像也不触发。
|
|
92
|
+
上升沿触发一次应答:优先凭据库自动填充(每条命令只试一次),
|
|
93
|
+
失败或无凭据弹 ask_password 模态。结尾判定取**最后一个**匹配:
|
|
94
|
+
首次应答后旧提示符仍躺在尾窗里,search 首匹配会让 at_end 恒 False
|
|
95
|
+
——ssh 输错重讨(deny+prompt 常被隧道合包成一块)时上升沿永不复现、
|
|
96
|
+
模态不再弹(真机根因)。模态在屏等应答期间尾窗不再以提示符结尾
|
|
97
|
+
(提示符被终端手输消费)时防抖代关陈旧模态(_pw_dismiss_release)。"""
|
|
98
|
+
self.core._pw_tail.extend(data)
|
|
99
|
+
if len(self.core._pw_tail) > 4096:
|
|
100
|
+
del self.core._pw_tail[:-2048]
|
|
101
|
+
tail = bytes(self.core._pw_tail)
|
|
102
|
+
last = None
|
|
103
|
+
for m in _PASSWORD_PROMPT_RE.finditer(tail):
|
|
104
|
+
last = m
|
|
105
|
+
at_end = last is not None and last.end() >= len(tail) - 4
|
|
106
|
+
self.core._pw_at_end = at_end
|
|
107
|
+
if self.core._pw_modal_open:
|
|
108
|
+
if at_end:
|
|
109
|
+
self._cancel_pw_dismiss() # 重讨:模态仍有用,保住
|
|
110
|
+
else:
|
|
111
|
+
self._schedule_pw_dismiss()
|
|
112
|
+
if self.core._pw_hold:
|
|
113
|
+
if at_end:
|
|
114
|
+
self._cancel_pw_hold_release() # 重新讨密码:继续 hold
|
|
115
|
+
else:
|
|
116
|
+
self._schedule_pw_hold_release()
|
|
117
|
+
if self.core._pw_verifying and (
|
|
118
|
+
not at_end
|
|
119
|
+
or time.monotonic() - self.core._pw_verify_at > _impl._PW_VERIFY_TIMEOUT):
|
|
120
|
+
self._end_pw_verify() # auth 结果已出(或超时):不再 hold 新输入
|
|
121
|
+
if not at_end:
|
|
122
|
+
self._pw_auth_result() # 成败分流:验证通过才排「记住」询问
|
|
123
|
+
if not at_end and self.core.nested.innermost_ssh_frame() is not None:
|
|
124
|
+
# 嵌套 ssh 登录成功:后台主动注入 hook——首条自然语言到达时
|
|
125
|
+
# hook 已在位走健康路径,免当场探测+分片注入的秒级代价
|
|
126
|
+
asyncio.get_running_loop().create_task(self.core.exec._proactive_reinject())
|
|
127
|
+
if not at_end:
|
|
128
|
+
self.core._pw_prompt_seen = False
|
|
129
|
+
if self.core._pw_capture_key is not None:
|
|
130
|
+
# 提示符被冲走/中止(Ctrl+C 等):半截口令作废不进库
|
|
131
|
+
self.core._pw_capture_key = None
|
|
132
|
+
self.core._pw_typed = bytearray()
|
|
133
|
+
return
|
|
134
|
+
if self.core._pw_prompt_seen or self.core.session is None:
|
|
135
|
+
return
|
|
136
|
+
if not self.armed:
|
|
137
|
+
return
|
|
138
|
+
self.core._pw_prompt_seen = True
|
|
139
|
+
asyncio.get_running_loop().create_task(self._on_nested_password_prompt())
|
|
140
|
+
|
|
141
|
+
# --- 验证窗口输入 hold 与释放 ---
|
|
142
|
+
|
|
143
|
+
def _begin_pw_verify(self) -> None:
|
|
144
|
+
"""密码打进 PTY:进入验证窗口(_on_keys/_on_frontend_line 开始 hold)。
|
|
145
|
+
|
|
146
|
+
同时复位 _pw_prompt_seen:本提示符已应答,ssh 重讨时上升沿必须复现
|
|
147
|
+
模态(否则 deny+prompt 合包到达时会被 seen 闸挡住)。"""
|
|
148
|
+
self.core._pw_prompt_seen = False
|
|
149
|
+
self.core._pw_verifying = True
|
|
150
|
+
self.core._pw_verify_at = time.monotonic()
|
|
151
|
+
self.core._pw_result_from = len(self.core._pw_tail) # 失败指纹的查询起点
|
|
152
|
+
self._schedule_pw_verify_expiry()
|
|
153
|
+
|
|
154
|
+
def _end_pw_verify(self) -> None:
|
|
155
|
+
"""退出验证窗口并撤销强制到期定时器。"""
|
|
156
|
+
self.core._pw_verifying = False
|
|
157
|
+
t = getattr(self.core, "_pw_verify_timer", None)
|
|
158
|
+
if t is not None:
|
|
159
|
+
t.cancel()
|
|
160
|
+
self.core._pw_verify_timer = None
|
|
161
|
+
|
|
162
|
+
def _schedule_pw_verify_expiry(self) -> None:
|
|
163
|
+
t = getattr(self.core, "_pw_verify_timer", None)
|
|
164
|
+
if t is not None:
|
|
165
|
+
t.cancel()
|
|
166
|
+
self.core._pw_verify_timer = asyncio.get_running_loop().create_task(
|
|
167
|
+
self._pw_verify_expiry())
|
|
168
|
+
|
|
169
|
+
async def _pw_verify_expiry(self) -> None:
|
|
170
|
+
"""验证窗口强制到期:输出驱动的超时判定在 shell 无输出时永远跑不到
|
|
171
|
+
(_detect_password_prompt 只在 pump 收到字节时被调用),验证窗口就此
|
|
172
|
+
永久开启、前端输入被永久 hold(真机「终端命令行无法输入任何文字」)。
|
|
173
|
+
这里用独立定时器兜底:到点即放行,已 hold 的输入依序释放。"""
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.sleep(_impl._PW_VERIFY_TIMEOUT)
|
|
176
|
+
except asyncio.CancelledError:
|
|
177
|
+
raise
|
|
178
|
+
finally:
|
|
179
|
+
self.core._pw_verify_timer = None
|
|
180
|
+
if not self.core._pw_verifying:
|
|
181
|
+
return
|
|
182
|
+
self.core._pw_verifying = False
|
|
183
|
+
self.core._pw_remember_candidate = None # 未见 auth 结果:不算验证通过
|
|
184
|
+
_dbg("pw verify expiry -> release hold", len(self.core._pw_hold))
|
|
185
|
+
if self.core._pw_hold:
|
|
186
|
+
self._schedule_pw_hold_release()
|
|
187
|
+
|
|
188
|
+
def _schedule_pw_hold_release(self) -> None:
|
|
189
|
+
if self.core._pw_hold_timer is None:
|
|
190
|
+
self.core._pw_hold_timer = asyncio.get_running_loop().create_task(
|
|
191
|
+
self._pw_hold_release())
|
|
192
|
+
|
|
193
|
+
def _cancel_pw_hold_release(self) -> None:
|
|
194
|
+
if self.core._pw_hold_timer is not None:
|
|
195
|
+
self.core._pw_hold_timer.cancel()
|
|
196
|
+
self.core._pw_hold_timer = None
|
|
197
|
+
|
|
198
|
+
async def _pw_hold_release(self) -> None:
|
|
199
|
+
"""hold 释放防抖:不以提示符结尾的 auth 结果输出(登录成功 / ssh 退出)
|
|
200
|
+
稍等才释放——隧道合包可能把 deny 与重讨提示符拆成两块到达,即放会
|
|
201
|
+
把抢跑命令打进新密码位。"""
|
|
202
|
+
try:
|
|
203
|
+
await asyncio.sleep(_impl._PW_HOLD_RELEASE)
|
|
204
|
+
except asyncio.CancelledError:
|
|
205
|
+
raise
|
|
206
|
+
finally:
|
|
207
|
+
self.core._pw_hold_timer = None
|
|
208
|
+
if not self.core._pw_hold or self.core._pw_at_end:
|
|
209
|
+
return
|
|
210
|
+
await self._flush_pw_hold()
|
|
211
|
+
|
|
212
|
+
async def _flush_pw_hold(self) -> None:
|
|
213
|
+
"""验证结束:按原序释放 hold 的输入——抢跑命令落在登录后的 shell 上
|
|
214
|
+
(用户预期「连接后命令」);自然语言行按「尚未键入」重走 submit 链
|
|
215
|
+
(字符当时被 hold,不在 shell buffer 里)。"""
|
|
216
|
+
hold, self.core._pw_hold = self.core._pw_hold, []
|
|
217
|
+
for item in hold:
|
|
218
|
+
if self.core.session is None:
|
|
219
|
+
return
|
|
220
|
+
if item[0] == "raw":
|
|
221
|
+
await self.core.session.send_raw(item[1])
|
|
222
|
+
else:
|
|
223
|
+
await self.core.ai._on_frontend_line(item[1], item[2], typed=False)
|
|
224
|
+
|
|
225
|
+
# --- 陈旧密码模态代关 ---
|
|
226
|
+
|
|
227
|
+
def _schedule_pw_dismiss(self) -> None:
|
|
228
|
+
if self.core._pw_dismiss_timer is None:
|
|
229
|
+
self.core._pw_dismiss_timer = asyncio.get_running_loop().create_task(
|
|
230
|
+
self._pw_dismiss_release())
|
|
231
|
+
|
|
232
|
+
def _cancel_pw_dismiss(self) -> None:
|
|
233
|
+
if self.core._pw_dismiss_timer is not None:
|
|
234
|
+
self.core._pw_dismiss_timer.cancel()
|
|
235
|
+
self.core._pw_dismiss_timer = None
|
|
236
|
+
|
|
237
|
+
async def _pw_dismiss_release(self) -> None:
|
|
238
|
+
"""模态代关防抖:密码提示符被终端手输消费(登录横幅/目标提示符把提示
|
|
239
|
+
符冲出尾窗结尾)时模态没有自己的关闭路径——不代关则用户登录成功后
|
|
240
|
+
仍面对讨密码的陈旧模态(真机:ssh 早已成功、模态悬在 root 提示符上)。
|
|
241
|
+
防抖一拍:期间重讨(at_end 回 True)取消代关、模态继续等应答。"""
|
|
242
|
+
try:
|
|
243
|
+
await asyncio.sleep(_impl._PW_MODAL_DISMISS)
|
|
244
|
+
except asyncio.CancelledError:
|
|
245
|
+
raise
|
|
246
|
+
finally:
|
|
247
|
+
self.core._pw_dismiss_timer = None
|
|
248
|
+
if not self.core._pw_modal_open or self.core._pw_at_end or self.core._closed:
|
|
249
|
+
return
|
|
250
|
+
self.core._pw_modal_open = False
|
|
251
|
+
await self.core.emit_msg(ServerMsg(type="ask_password_dismiss"))
|
|
252
|
+
|
|
253
|
+
def _pw_prompt_host(self) -> tuple[str | None, str | None]:
|
|
254
|
+
"""从尾窗的密码提示符行里提取 OpenSSH 自带的 user@host。
|
|
255
|
+
|
|
256
|
+
OpenSSH 两种提示格式:``(user@host) Password:``(新版)与
|
|
257
|
+
``user@host's password:``(旧版)。提示符自带主机 = 凭据归属的
|
|
258
|
+
最强凭据(sudo 在嵌套会话内讨密码时不会错用外层 ssh 主机的
|
|
259
|
+
凭据);无主机信息返回 (None, None)。"""
|
|
260
|
+
m = re.search(
|
|
261
|
+
rb"\(([^()\r\n@]+)@([^()\r\n@]+)\)\s*password"
|
|
262
|
+
rb"|([^\s@)\r\n]+)@([^\s@)\r\n']+)'s password",
|
|
263
|
+
bytes(self.core._pw_tail), re.IGNORECASE)
|
|
264
|
+
if m is None:
|
|
265
|
+
return None, None
|
|
266
|
+
user = (m.group(1) or m.group(3) or b"").decode("utf-8", "replace")
|
|
267
|
+
host = (m.group(2) or m.group(4) or b"").decode("utf-8", "replace")
|
|
268
|
+
return (host or None), (user or None)
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def armed(self) -> bool:
|
|
272
|
+
"""最内层未闭合用户命令是否 ssh/sudo 类(提示符检测的触发门槛)。
|
|
273
|
+
|
|
274
|
+
三条路径覆盖三种命令执行方式:
|
|
275
|
+
① _exec_stack——AI/探测的 EXEC 帧(C/D 标记驱动);
|
|
276
|
+
② _open_cmds——用户手敲命令的记账帧(C/D 标记驱动);
|
|
277
|
+
③ _cs_pw_pending——命令集 _cs_command 直发 PTY 的密码类命令
|
|
278
|
+
(不走 C/D 标记,前两条路都看不到;真机:跳板命令集
|
|
279
|
+
`ssh pe@目标` 后密码提示自动填充被 _pw_armed 拦死)。"""
|
|
280
|
+
for frame in reversed(self.core._exec_stack):
|
|
281
|
+
line = frame.get("line") or ""
|
|
282
|
+
if line and not is_internal_line(line):
|
|
283
|
+
return _needs_password_prompt(line)
|
|
284
|
+
for line in reversed(list(self.core._open_cmds.values())):
|
|
285
|
+
return _needs_password_prompt(line)
|
|
286
|
+
return bool(getattr(self.core, "_cs_pw_pending", False))
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _load_nested_password(host: str, user: str | None,
|
|
290
|
+
port: int | None) -> str | None:
|
|
291
|
+
"""嵌套凭据读取:无端口与 ssh 默认端口 22 视同同一键。
|
|
292
|
+
|
|
293
|
+
凭据键 display_name 在端口非空时附 :port——记住的连接档案流按
|
|
294
|
+
target.port=22 存 ``user@host:22``,嵌套 ssh 命令无 -p 解析出
|
|
295
|
+
port=None 查 ``user@host``:同一密码两个键、自动填充永远 miss
|
|
296
|
+
(真机:档案登录成功过、嵌套 ssh 仍弹窗重讨)。两键互为别名,
|
|
297
|
+
已落在任一键下的旧条目都读得到。规范实现在
|
|
298
|
+
connections.secrets.load_password_alias(首跳 _connect 共用)。"""
|
|
299
|
+
return _impl.load_password_alias(host, user, port)
|
|
300
|
+
|
|
301
|
+
async def _on_nested_password_prompt(self) -> None:
|
|
302
|
+
"""密码提示符上升沿:凭据库自动填充一次,否则交给终端手输。
|
|
303
|
+
|
|
304
|
+
凭据键优先取提示符自带的 user@host(_pw_prompt_host,归属最强);
|
|
305
|
+
提示符无主机信息时回退最内层 ssh 帧解析(_nested_ssh_target);
|
|
306
|
+
双双落空 = sudo/su 类无主机提示符 → sudo@<主机> 专用键
|
|
307
|
+
(_sudo_pw_key,P1-3)。自动填充每条命令只试一次:密码错误时 ssh
|
|
308
|
+
会重新讨,第二次起不再静默代答,避免凭据过期时连环静默失败。
|
|
309
|
+
**无凭据不弹模态**——提示符本来就在终端里(真实 ssh/sudo 行为),
|
|
310
|
+
用户直接手输;旧实现弹窗代答,手输消费提示符后模态被防抖代关,
|
|
311
|
+
表现为弹框一闪而过(真机 2026-10-08)。手输的密码经捕获留档
|
|
312
|
+
(_arm_typed_capture),验证成功后弹一次「记住」询问(P1-3/P1-4)。"""
|
|
313
|
+
if self.core._closed or self.core.session is None:
|
|
314
|
+
return
|
|
315
|
+
attempt = self.core._nested_pw_attempts
|
|
316
|
+
self.core._nested_pw_attempts += 1
|
|
317
|
+
host, user, port = (None, None, None)
|
|
318
|
+
phost, puser = self._pw_prompt_host()
|
|
319
|
+
if phost is not None:
|
|
320
|
+
host, user = phost, puser
|
|
321
|
+
thost, tuser, tport = self.core.nested.nested_ssh_target()
|
|
322
|
+
if thost == host: # 提示符主机与命令一致:补全端口
|
|
323
|
+
port, user = tport, (tuser or user)
|
|
324
|
+
else:
|
|
325
|
+
host, user, port = self.core.nested.nested_ssh_target()
|
|
326
|
+
key = (host, user, port) if host is not None else self.core.nested.sudo_pw_key()
|
|
327
|
+
self.core._nested_pw_ctx = key
|
|
328
|
+
if key is not None and key[0] and attempt == 0:
|
|
329
|
+
pw = self._load_nested_password(key[0], key[1], key[2])
|
|
330
|
+
if pw:
|
|
331
|
+
await self.core.session.send_raw(pw.encode() + b"\r")
|
|
332
|
+
self._begin_pw_verify()
|
|
333
|
+
self.core._pw_remember_candidate = None # 凭据库已有,无需询问
|
|
334
|
+
await self.core.emit_msg(ServerMsg(
|
|
335
|
+
type="status",
|
|
336
|
+
text=f"已用记住的密码自动填充 {_impl.display_name(*key)}"))
|
|
337
|
+
return
|
|
338
|
+
# 无凭据/自动填充未命中:武装手输捕获——用户在终端手输密码(提示符
|
|
339
|
+
# 已可见),验证成功后询问一次「记住」(P1-3 sudo/su 键 + P1-4 嵌套
|
|
340
|
+
# ssh 键)。模态代答路径(_on_nested_password)保留但不再触发。
|
|
341
|
+
self._arm_typed_capture(key)
|
|
342
|
+
|
|
343
|
+
async def _on_nested_password(self, pw: str, remember: bool) -> None:
|
|
344
|
+
"""模态提交的嵌套密码:整行打进 PTY(远端 echo 关闭,不回显)。"""
|
|
345
|
+
self.core._pw_modal_open = False
|
|
346
|
+
self._cancel_pw_dismiss()
|
|
347
|
+
# 模态接管:手输捕获与旧候选作废(密码来源是弹窗,记忆只走勾选)
|
|
348
|
+
self.core._pw_capture_key = None
|
|
349
|
+
self.core._pw_typed = bytearray()
|
|
350
|
+
self.core._pw_remember_candidate = None
|
|
351
|
+
if self.core.session is not None and pw:
|
|
352
|
+
await self.core.session.send_raw(pw.encode() + b"\r")
|
|
353
|
+
self._begin_pw_verify()
|
|
354
|
+
if remember:
|
|
355
|
+
host, user, port = self.core._nested_pw_ctx or (None, None, None)
|
|
356
|
+
if host is not None:
|
|
357
|
+
try:
|
|
358
|
+
_impl.store_password(host, user, port, pw)
|
|
359
|
+
except Exception:
|
|
360
|
+
pass # 凭据库缺席不阻断本次登录
|
|
361
|
+
|
|
362
|
+
# --- 手输密码捕获与「记住」询问(P1-3/P1-4)---
|
|
363
|
+
|
|
364
|
+
def _arm_typed_capture(self, key) -> None:
|
|
365
|
+
"""无凭据/填充未命中:武装手输捕获——用户在终端打出的密码留档候选。
|
|
366
|
+
|
|
367
|
+
key=None(「记住」没有可靠的键)不捕获不询问。重新武装时旧候选
|
|
368
|
+
作废:错密/中途放弃的密码不进库(只落本次验证通过的)。"""
|
|
369
|
+
self.core._pw_typed = bytearray()
|
|
370
|
+
self.core._pw_remember_candidate = None
|
|
371
|
+
self.core._pw_capture_key = key
|
|
372
|
+
|
|
373
|
+
def _pw_typed_capture(self, data: bytes) -> None:
|
|
374
|
+
"""手输密码旁路留档(前端 _pwFeed 同规则的后端版):只记录,按键
|
|
375
|
+
照常走原路(透传/hold)——提示符在终端里,密码必须照达远端。
|
|
376
|
+
|
|
377
|
+
可打印字节进缓冲;退格删末字符(按 UTF-8 字符);ESC 序列整体
|
|
378
|
+
跳过(方向键等不是口令);\\r/\\n 冻结候选并进验证窗口。"""
|
|
379
|
+
if self.core._pw_capture_key is None:
|
|
380
|
+
return
|
|
381
|
+
buf = self.core._pw_typed
|
|
382
|
+
i = 0
|
|
383
|
+
while i < len(data):
|
|
384
|
+
b = data[i]
|
|
385
|
+
if b in (0x0D, 0x0A):
|
|
386
|
+
self._pw_capture_submit()
|
|
387
|
+
return # 回车后的字节不属于本次口令(粘贴尾巴丢弃)
|
|
388
|
+
if b == 0x1B:
|
|
389
|
+
# CSI(ESC [ … 终字节)/ SS3(ESC O x)整体跳过
|
|
390
|
+
if i + 1 < len(data) and data[i + 1] in (ord("["), ord("O")):
|
|
391
|
+
i += 2
|
|
392
|
+
while i < len(data) and not 0x40 <= data[i] <= 0x7E:
|
|
393
|
+
i += 1
|
|
394
|
+
i += 1
|
|
395
|
+
else:
|
|
396
|
+
i += 2
|
|
397
|
+
continue
|
|
398
|
+
if b in (0x7F, 0x08):
|
|
399
|
+
while buf:
|
|
400
|
+
c = buf.pop()
|
|
401
|
+
if (c & 0xC0) != 0x80:
|
|
402
|
+
break # 删掉末字符(含其 UTF-8 续字节)
|
|
403
|
+
i += 1
|
|
404
|
+
continue
|
|
405
|
+
if b >= 0x20:
|
|
406
|
+
buf.append(b)
|
|
407
|
+
i += 1
|
|
408
|
+
|
|
409
|
+
def _pw_capture_submit(self) -> None:
|
|
410
|
+
"""捕获的密码随回车提交:冻结候选并进验证窗口(等 auth 结果)。
|
|
411
|
+
|
|
412
|
+
与模态路径 _on_nested_password 同语义:提交即 hold 抢跑输入、
|
|
413
|
+
复位提示符 seen 让重讨上升沿复现。空密码不设候选(远端自会
|
|
414
|
+
拒绝/放行),但同样进验证窗。"""
|
|
415
|
+
key, self.core._pw_capture_key = self.core._pw_capture_key, None
|
|
416
|
+
if key is None:
|
|
417
|
+
return
|
|
418
|
+
typed, self.core._pw_typed = self.core._pw_typed, bytearray()
|
|
419
|
+
self._begin_pw_verify()
|
|
420
|
+
if typed:
|
|
421
|
+
self.core._pw_remember_candidate = (
|
|
422
|
+
key, bytes(typed).decode("utf-8", "replace"))
|
|
423
|
+
|
|
424
|
+
def _pw_result_tail(self) -> bytes:
|
|
425
|
+
"""本次验证窗起点之后的输出——失败指纹的查询范围(上一轮错密的
|
|
426
|
+
deny 还留在尾窗里,全窗扫会把重试成功误判成失败)。"""
|
|
427
|
+
return bytes(self.core._pw_tail[self.core._pw_result_from:])
|
|
428
|
+
|
|
429
|
+
def _pw_auth_result(self) -> None:
|
|
430
|
+
"""验证窗口以 auth 结果输出收束(not at_end):成败分流。
|
|
431
|
+
|
|
432
|
+
失败(deny 指纹)错密不进库也不问;成功把候选排进「记住」询问
|
|
433
|
+
(防抖 _impl._PW_REMEMBER_DELAY——等迟到的 deny/重讨落地再问,「只落
|
|
434
|
+
本次验证通过的密码」)。"""
|
|
435
|
+
cand = self.core._pw_remember_candidate
|
|
436
|
+
if cand is None:
|
|
437
|
+
return
|
|
438
|
+
if _impl._PW_AUTH_FAIL_RE.search(self._pw_result_tail()):
|
|
439
|
+
self.core._pw_remember_candidate = None
|
|
440
|
+
return
|
|
441
|
+
asyncio.get_running_loop().create_task(self._pw_remember_offer(cand))
|
|
442
|
+
|
|
443
|
+
async def _pw_remember_offer(self, cand) -> None:
|
|
444
|
+
"""验证成功后的轻量「记住」询问(防抖一拍)。应答经
|
|
445
|
+
remember_yes/remember_no → _finish_remember;文案对齐 ready 后的
|
|
446
|
+
「记住密码」确认框。"""
|
|
447
|
+
await asyncio.sleep(_impl._PW_REMEMBER_DELAY)
|
|
448
|
+
if self.core._closed or self.core._pw_remember_candidate is not cand:
|
|
449
|
+
return # 新一轮输入/候选已接管
|
|
450
|
+
self.core._pw_remember_candidate = None
|
|
451
|
+
if self.core._pw_at_end or _impl._PW_AUTH_FAIL_RE.search(self._pw_result_tail()):
|
|
452
|
+
return # 重讨提示又回屏/迟到失败指纹:错密不进库
|
|
453
|
+
self.core._pw_remember_pending = cand
|
|
454
|
+
key, _pw = cand
|
|
455
|
+
disp = _impl.display_name(*key)
|
|
456
|
+
text = (f"密码验证通过({disp})。记住该密码?下次自动填充"
|
|
457
|
+
if key[1] == "sudo" else
|
|
458
|
+
f"已成功登录 {disp}。记住该密码?下次自动填充")
|
|
459
|
+
await self.core.emit_msg(ServerMsg(
|
|
460
|
+
type="ask_remember", label=disp,
|
|
461
|
+
text=text + "(写入系统凭据库,不落数据库)"))
|
|
462
|
+
|
|
463
|
+
async def _finish_remember(self, store: bool) -> None:
|
|
464
|
+
"""「记住」询问应答:同意写 keyring(失败可见),拒绝只清档不写。"""
|
|
465
|
+
pending, self.core._pw_remember_pending = self.core._pw_remember_pending, None
|
|
466
|
+
if not store or pending is None:
|
|
467
|
+
return
|
|
468
|
+
(host, user, port), pw = pending
|
|
469
|
+
if not _impl.store_password(host, user, port, pw):
|
|
470
|
+
await self.core.emit_msg(ServerMsg(
|
|
471
|
+
type="status", text=_impl.PW_STORE_FAIL_HINT))
|
|
472
|
+
|
|
473
|
+
async def ask_password(self, label: str, auth_kind: str = "") -> str:
|
|
474
|
+
await self.core.emit_msg(ServerMsg(
|
|
475
|
+
type="ask_password", label=label, auth_kind=auth_kind))
|
|
476
|
+
val = await self.core._pws.get()
|
|
477
|
+
if val is None:
|
|
478
|
+
if getattr(self.core, "_pw_cancelled", False):
|
|
479
|
+
self.core._pw_cancelled = False
|
|
480
|
+
raise PasswordCancelled()
|
|
481
|
+
raise ConnectionError("连接已关闭")
|
|
482
|
+
return val
|
|
483
|
+
|
|
484
|
+
def store_auth_password(self, password: str) -> bool:
|
|
485
|
+
"""认证弹窗勾选「记住」→ 密码写入 keyring(与重连时 load_password 同一键)。
|
|
486
|
+
|
|
487
|
+
键 = 目标的 display_name 三元组——ad-hoc 临时目标(
|
|
488
|
+
_resolve_target_name 重建)同样按它落键(P2-5:确认后写 keyring,
|
|
489
|
+
未确认不写由 ready 后确认框把关)。返回是否落库成功:凭据库不可用
|
|
490
|
+
返回 False,由调用方给可见提示(「以为记住了其实没记住」不再静默)。
|
|
491
|
+
"""
|
|
492
|
+
target = self.core.cfg.targets.get(self.core.target_name, self.core.cfg.targets["default"])
|
|
493
|
+
if target.mode != "ssh" or not target.host:
|
|
494
|
+
return True # 无键可写(local 等):不算失败
|
|
495
|
+
try:
|
|
496
|
+
return bool(_impl.store_password(target.host, target.user, target.port,
|
|
497
|
+
password))
|
|
498
|
+
except Exception: # noqa: BLE001 - 记住失败不阻断本次连接(密码已进 _pws 继续认证)
|
|
499
|
+
return False
|