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.
Files changed (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. 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